Documentation menu

Events API

Reference for POST /api/events — fields, limits, levels, idempotency, rate limits, and every response code.

One endpoint, one method, JSON in. Everything else in EventSend is configured in the dashboard rather than in the request.

POST https://eventsend.io/api/events
Authorization: Bearer es_YOUR_TOKEN
Content-Type: application/json

Authentication

The route token is the credential, and it travels in an Authorization: Bearer header. Every token starts with es_, and anything that doesn't is rejected with 422. Keep it server-side.

The path form

POST https://eventsend.io/api/events/es_YOUR_TOKEN is the original form and stays supported indefinitely — same authorization, same idempotency, same quota, same response codes. Prefer the header anyway: a credential in a URL is written to every proxy and edge access log it passes through, kept in shell history and CI job logs, and sent in a Referer if it ever reaches a browser. None of that is true of a header.

You may send either form, not both: a request carrying a path token and a Bearer token that disagree is rejected with 422 and "error": "Conflicting credentials" rather than one of them being picked. Sending the same token in both places is fine.

If a token does leak, Regenerate Token on the route (Routes → the route's settings → Danger Zone) issues a new one and stops the old one immediately — events already accepted are still delivered.

Request fields

Field Type Required Notes
event string yes The event name, 4–64 characters. This is what topic patterns match against.
message string yes Human-readable text, 4–500 characters. This is the body of the delivered message.
payload object yes Structured data, at most 4096 bytes once JSON-encoded. Send {} if you have nothing to attach.
level string no One of default, success, warning, error, critical. Defaults to default.
icon string no An emoji or short string, 1–16 characters, shown next to the message.
unique_key string no 4–128 characters. Makes the request idempotent — see below.
user_id string no 1–128 characters. Your own stable identifier for the customer this event is about — an account id, or your Stripe customer id. Matched exactly: it powers the user filter on the events page and the per-customer timelines the MCP server answers, so send the same value from every system that reports on a customer (a Stripe source uses the Stripe customer id). Not an EventSend user, and not an email — put the email in payload.
occurred_at string no ISO-8601 timestamp of when the thing happened, e.g. 2026-09-16T10:15:00Z. Defaults to the moment EventSend received the request. Must be no more than 5 minutes in the future and no more than 366 days in the past.

The whole request body is capped at 32 KB. Anything larger is rejected with 413 before it is parsed, so keep payload well under the 4096-byte field limit rather than relying on the body cap.

One character is off-limits: the null character \u0000 (U+0000) cannot be stored, so a request carrying it in any string field — or in any key or string value inside payload — is rejected with 422 naming the field. It usually arrives by accident (a truncated C string, a binary value, text pasted from a log), and since no retry can make it storable, strip it before sending. Every other character, control characters and emoji included, is fine.

Levels

Levels are ordered defaultsuccesswarningerrorcritical. They drive a topic's minimum-level rule and the color of the delivered message. See routing rules for how filtering works.

Idempotency

If you send unique_key, EventSend will not create a second event for that key. Use it whenever the caller might retry: a webhook handler, a CI job that can be re-run, a queue with at-least-once delivery. A commit SHA, an invoice id, or an error fingerprint all make good keys. Without unique_key, every request creates a new event.

The guarantee is about the event, not about the status code. Four properties follow from that, and they are worth knowing before you rely on it.

409 is a five-minute fast path, not the whole guarantee. Recently-seen keys are held in a five-minute cache, and a repeat inside that window is refused synchronously with 409 Conflict. Once the key ages out, the check moves to the stored event itself: the request is answered 202, and the duplicate is discarded when its queued job finds the original. No second event is created either way — but a retry an hour later gets 202, not 409, so never read 202 as "this one was new".

The key space is your whole organization, not the route. The same unique_key sent to a staging route and a production route is a duplicate, and the second one is rejected. Namespace your keys (staging:build-4417) if you want the same identifier to travel through more than one route.

The guarantee ends when the event is pruned. Events are deleted at the end of your plan's retention window (7 days on Free, 30 on Starter, 60 on Growth, 90 on Scale), and once an event is gone its key is free again: the same unique_key will be accepted as a genuinely new event. Idempotency protects you against a retry storm, not against a replay months later.

Two concurrent requests carrying the same key can both be accepted. Only one event is ever created — whichever queued job commits first wins, and the other is dropped — but the race is resolved after both responses have been sent. Treat 202 as "this event is queued", never as "this request is the one that created it".

Responses

Status Meaning
202 Queued: {"success": true, "message": "Event received"}. The only status that means your event was accepted.
200 The route is in debug mode, so the event was validated and deliberately not enqueued: {"accepted": false, "debug": true, "message": "Debug mode enabled - event not enqueued"}.
404 Unknown token: {"success": false, "error": "Invalid token"}.
409 A previous event already used this unique_key: {"success": false, "error": "Duplicate event"}.
413 Request body larger than 32 KB.
422 Two different shapes. Validation failed: {"success": false, "error": "Invalid event data", "details": {...}}, where details names each rejected field. Or the credential is malformed, missing or presented twice with different values: {"success": false, "error": "Invalid token"} / {"success": false, "error": "Conflicting credentials"}, with no details key.
429 Rate limited — by your client's address, or by this route — or your plan's monthly event allowance is spent. See below. All three carry a retry-after header in seconds.
503 Temporary internal problem: {"success": false, "error": "Temporarily unavailable, please retry"}. Safe to retry.

202 is the only status that means the event was accepted, and it means queued for processing — not "recorded and delivered". Delivery happens asynchronously with retries — see Delivery and retries for the schedule and what is promised — and the dashboard shows per-destination attempts. Anything else, 2xx included, means no event was queued.

Debug mode

If your route is in debug mode, requests are validated and answered 200 with {"accepted": false, "debug": true}. Nothing is enqueued, nothing is recorded, and no quota is consumed. A debug route never returns 202, so a client that checks for 202 before considering an event sent will notice the route is in debug mode instead of silently dropping events.

When your plan's allowance is spent

The Free plan has a hard cap. Once its monthly allowance is used, ingest answers 429 with {"success": false, "error": "Monthly event limit reached"} and a retry-after header holding the number of seconds until your usage period ends. The event is not queued and not recorded.

Two honest qualifications. The check reads a usage snapshot that refreshes every couple of minutes, so right at the boundary a few more requests can still be answered 202 after the allowance is really gone; the cap is enforced again when the event is written, and those events are discarded rather than delivered — nothing is recorded and nothing is billed. And retry-after is a floor, not a promise: if your period has just ended and the rollover has not happened yet, a retry at exactly that moment can return 429 again.

Paid plans have no hard cap: usage above the allowance keeps returning 202 and bills as overage.

The three kinds of 429 are told apart by the body: the per-IP limiter answers a plain-text Too Many Requests, the per-route limit answers {"success": false, "error": "Rate limit exceeded"}, and an exhausted allowance answers the JSON body above. retry-after is always in seconds, though an exhausted allowance's value can be days' worth of them. Retrying any of the three is safe.

Rate limits

Two limits apply, in this order:

  • Per client IP: 1 request per second, burst 10. An abuse backstop, shared by everything behind the same address. Answered with a plain-text Too Many Requests.
  • Per route: 10 requests per second, burst 100. Your route's own budget, so a token that leaks cannot be spent faster than this no matter how many machines present it. Answered with {"success": false, "error": "Rate limit exceeded"}.

Both carry a retry-after header in seconds. Batch or spread out bulk backfills; for steady application traffic these are far above typical event rates.

Sources ingest

Events from a third-party provider use a different endpoint, because the provider — not you — controls the request:

POST https://eventsend.io/api/sources/:token

The token starts with src_. EventSend verifies the provider's signature over the exact raw request body before parsing anything, and answers 401 if it doesn't match (or if the source has no signing secret configured yet).

Otherwise this endpoint is deliberately generous with 2xx, because providers disable endpoints that keep failing. A duplicate delivery, an event type you haven't enabled, a paused source, an inactive route, a debug-mode route, or an exhausted quota all return 200 with a message explaining what happened — only a bad signature, an unknown token, an oversized body (512 KB here), a burst past the rate limits below, or a genuine server problem are non-2xx. That is the one place this differs from the token endpoint on purpose: a spent allowance answers 429 there, because you control the retry, and 200 here, because the provider would take a 429 as a reason to stop delivering to you.

Idempotency is automatic: the provider's own event id is used as the unique_key, so provider retries never produce duplicate events. Source requests are limited to 20 requests per second per IP (burst 200) and 10 per second per source (burst 100). The token goes in the path here because the provider composes the request, not you — which is also why sources have their own signature check.

See Stripe for a concrete setup.