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 default → success → warning → error → critical. 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.