Webhooks
Delivery events pushed to your endpoint, signed, with the verification code you need.
A send returns before the email is delivered, so the interesting part happens afterwards. Register an endpoint and we POST every event about your mail to it as it happens: no polling, no cron, no waiting on GET /v1/emails/:id in a loop.
Register an endpoint
curl -X POST https://emails.sh/v1/webhooks \
-H "Authorization: Bearer $EMAILSSH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://acme.com/hooks/emails",
"events": ["email.delivered", "email.bounced", "email.complained"]
}'{
"id": "wh_01J9X9B4T2",
"url": "https://acme.com/hooks/emails",
"events": ["email.delivered", "email.bounced", "email.complained"],
"secret": "whsec_2f8c1a4e9b7d0c53"
}Events
| Event | When it fires |
|---|---|
email.sent | The receiving mail server accepted the message from us. |
email.delivered | The receiving server confirmed delivery. Usually seconds after sent. |
email.bounced | Refused. data carries bounce_type, bounce_subtype, and the remote diagnostic. |
email.complained | The recipient marked it as spam. The address is suppressed automatically. Arrives hours or days later. |
email.received | Inbound mail arrived on a domain of yours with an MX record published. The payload is documented in full below, and it carries the whole message. |
email.filtered | Inbound mail dropped by one of your rules before it was stored. Fires instead of email.received, not as well as it. |
domain.verified | A domain finished verification. The one event a setup script waits on. |
workspace.throttled | Cold sending is being slowed. Clears on its own after a clean day. |
workspace.paused | Every send is refused until a person has looked. |
workspace.resumed | The throttle cleared. |
automation.run.started | An automation run entered its first step. |
automation.run.failed | A run failed somewhere inside it. |
That is the whole list. There is no email.opened and no email.clicked. An open is a pixel fetch that a privacy proxy often makes on the recipient behalf, so a stream of them is not something a receiver can act on; opens and clicks are counters on the message and rows in GET /v1/analytics instead. The three workspace events and the two automation events are account news rather than message news, so they reach only an endpoint that is not scoped to a single mailbox.
What arrives
POST /your-endpoint HTTP/1.1
content-type: application/json
x-emailssh-event: email.delivered
x-emailssh-delivery-id: whd_9c1f4a2b7e0d4c58a1b6
x-emailssh-signature: t=1753440000,v1=9f2c...e1
{
"id": "whd_9c1f4a2b7e0d4c58a1b6",
"event": "email.delivered",
"timestamp": "2026-07-28T09:14:02.117Z",
"data": {
"email_id": "em_01J9X8Q2K7Y4RN3M",
"to": ["ada@example.com"],
"subject": "Your receipt from Acme",
"tags": { "campaign": "receipt" }
}
}The tags you set on the send are echoed on every event for it, which is what lets you route an event to the right tenant or template without a database lookup.
Verify the signature
x-emailssh-signature is t=<unix seconds>,v1=<hex>. The digest is HMAC-SHA256 over "<timestamp>.<raw body>" with your endpoint secret. Check it against the raw bytes you received: re-serialising the parsed JSON reorders keys and produces a different string.
import { createHmac, timingSafeEqual } from 'node:crypto';
// The raw bytes, not a re-serialised object: JSON.stringify(JSON.parse(x))
// is not always x, and a reordered key is a signature that will not match.
export function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!Number.isFinite(age) || age > 300) return false;
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1 ?? '', 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}import hashlib
import hmac
import time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(part.split("=", 1) for part in header.split(","))
# Reject anything older than five minutes: a valid signature replayed a
# week later is still a valid signature.
if abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{parts['t']}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))During a rotation the header carries one v1 per valid secret, so accept the delivery if any of them matches. That is what makes rotating a secret possible without dropping events.
The delivery guarantee
At least once, unordered. Deduplicate on id, and sort by sequence. Those two sentences are the whole contract; the rest of this section is why.
At least once means the same event can arrive more than once. Retries, the held queue, the replay button, and dead-letter recovery all produce repeats on purpose, and a receiver we timed out on has usually already done the work. id is minted once per event and every copy carries it, in the body and in x-emailssh-delivery-id and x-request-id. Nothing else in the envelope is unique per event.
Unordered means two events about one message can arrive in either order, and routinely do. They are fanned out as independent queue jobs, delivered concurrently, and retried on independent curves, so a delivered that succeeded first overtakes a sent that needed one retry. Ordering them would mean serialising every workspace behind a single queue, and one slow receiver would then delay everybody. So every envelope carries sequence, and that is the field to sort on.
| Event | sequence |
|---|---|
email.received | 1 |
email.filtered | 1 |
email.sent | 2 |
email.delivered | 3 |
email.bounced | 3 |
email.complained | 4 |
anything with no message behind it | 0 |
Higher is later. A receiver holding "this message is delivered" can drop a sent that turns up afterwards with a lower number, rather than moving the row backwards. Equal numbers are genuinely unordered with respect to each other and you must not infer an order from them: a bounce and a delivery are exclusive outcomes of the same step, and so are received and filtered. timestamp is when we raised the event, which is a good tiebreak and not a guarantee, because it comes from whichever worker raised it.
Answer fast, and expect repeats
Reply 2xx within ten seconds. Anything else, including a timeout, is a failure. Do the work after you answer, not before.
| Attempt | Wait before it | Elapsed |
|---|---|---|
1 | immediate | 0 |
2 | 5s | 5s |
3 | 5m | 5m 5s |
4 | 30m | 35m 5s |
5 | 2h | 2h 35m 5s |
6 | 5h | 7h 35m 5s |
7 | 10h | 17h 35m 5s |
Seven attempts over about 17 hours and 35 minutes, which is the same curve Resend publishes. After the seventh the event is abandoned, and an abandoned event is written into the delivery log with the rest, so GET /v1/webhooks/deliveries is the complete record rather than a record of the attempts that got a response. An endpoint that answers with its own Retry-After gets exactly that instead of the curve, capped at an hour. The numbers here come from GET /v1/webhooks, which publishes retry_delays_sec, max_attempts, and retry_window_sec, so read them from there rather than copying this table.
Turning an endpoint off takes two things at once: 20 consecutive failed attempts AND 18 hours with nothing accepted. Both, because a count on its own means something different on a busy workspace than on a quiet one. At seven attempts an event, 20 failures in a row is a couple of days of a quiet workspace and under a minute of a busy one riding through a 30-second deploy, and switching somebody off for a deploy is the wrong answer. The silence window is published as auto_disable_after_silence_sec on GET /v1/webhooks. A 410 Gone bypasses both and turns the endpoint off at once, because that is the receiver telling us the URL is retired. Events raised while it is off are held for 7 days, up to 500 of them, and past that the oldest are dropped. A test delivery that lands turns it back on and releases what is held.
Delivery is at-least-once, so the same event can arrive twice and every copy carries the same id in the body and in x-emailssh-delivery-id. Record the id and let a repeat fall through.
// At-least-once: record the delivery id, and let a repeat fall straight through.
export async function handle(body: { id: string; event: string; data: unknown }) {
if (await seen.has(body.id)) return new Response('ok');
await seen.add(body.id, { ttlSeconds: 86_400 });
switch (body.event) {
case 'email.bounced':
await markUndeliverable(body.data);
break;
case 'email.complained':
await unsubscribe(body.data);
break;
}
return new Response('ok');
}Inbound mail: the email.received payload
This is the one to read if you are moving off SendGrid Inbound Parse or Postmark inbound. It carries everything a handler needs for the common case, so it never has to call back for the body, the sender, or whether the mail is genuine, plus thread_id, which neither of them has and which is the reason to receive here rather than there.
{
"id": "whd_4a71c0e8f2b94d6a8c13",
"event": "email.received",
"timestamp": "2026-07-28T09:14:02.117Z",
"sequence": 1,
"data": {
"message_id": "msg_01J9XA2P4W8HKQ6Z",
"thread_id": "thr_01J9XA2P4W8HKQ6Z",
"mailbox": "support@acme.com",
"from": "ada@example.com",
"from_name": "Ada Lovelace",
"to": ["support@acme.com"],
"cc": ["billing@acme.com"],
"subject": "Re: Your receipt from Acme",
"snippet": "Thanks, but I was charged twice for July.",
"text": "Thanks, but I was charged twice for July.\n\nAda",
"html": "<p>Thanks, but I was charged twice for July.</p>",
"date": "2026-07-28T09:13:58.000Z",
"message_id_header": "<CAF9x2-abc123@mail.example.com>",
"in_reply_to": "<receipt-8821@emails.sh>",
"references": ["<receipt-8821@emails.sh>"],
"list_id": null,
"headers": {
"from": "Ada Lovelace <ada@example.com>",
"subject": "Re: Your receipt from Acme",
"user-agent": "Mozilla Thunderbird"
},
"spam_verdict": "PASS",
"auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass" },
"automated": false,
"attachments": [
{
"filename": "statement.pdf",
"content_type": "application/pdf",
"size": 88213,
"url": "/v1/messages/msg_01J9XA2P4W8HKQ6Z/attachments/statement.pdf"
}
],
"inline_images": [
{
"filename": "signature.png",
"content_id": "sig-1@example.com",
"content_type": "image/png",
"size": 4210
}
]
}
}| Field | What it is |
|---|---|
message_id | The stored message. Use it for GET /v1/messages/:id and for the attachment routes. |
thread_id | The conversation, stable across the whole exchange. Reply in thread with POST /v1/messages/:id/reply-all. |
mailbox | The address of yours it arrived at, which is how you route by product or by tenant. |
from | The envelope sender, bare. |
from_name | The display name off the From header, or null. |
to, cc | Every address on the message, as arrays. bcc is not there because it never travels. |
subject | As sent, already decoded from any MIME encoding. |
snippet | The first line or so of the text body, for a list view. |
text, html | Both bodies, whichever the sender provided. Either can be null; a message with only HTML is common. |
date | The Date header as the sender wrote it. Not when we received it: timestamp on the envelope is that. |
message_id_header | The sender own Message-ID, for matching against records you already keep. |
in_reply_to, references | The threading chain, verbatim. thread_id is our answer to the same question and is easier. |
list_id | Set on mail from a mailing list. Present is a strong reason not to auto-reply. |
headers | Every header. Keys lowercased, repeats joined with a semicolon. |
spam_verdict | What the receiving filter thought. |
auth | SPF, DKIM and DMARC, each pass, fail, or none. A field rather than a header string, because code deciding whether to trust a message should read a value and not a regex. |
automated | A bounce or a vacation auto-reply, decided from the headers. Check it before replying: an auto-responder answering an auto-responder is a loop that ends with both domains suppressed. |
attachments | filename, content_type, size, and url. The bytes are fetched by id, not posted to you: a 25MB base64 body is a timeout rather than a feature. |
inline_images | Images the HTML references with cid:, rather than files the sender attached. Each carries content_id, which is what the cid: in the HTML matches. |
Gate on auth.dmarc for "is this really from who it says". SPF alone fails legitimately on any forwarded mail, and DKIM alone says the signing domain is intact without saying it matches the From address. Anything that is neither pass nor fail is none, which means no policy was published or DNS was briefly unreachable, and treating that as a failure would reject a great deal of real mail.
Fetch an attachment with GET https://emails.sh{url} and your API key, or mail.inbound.attachment(messageId, filename) in any SDK. The same auth block is on GET /v1/messages and GET /v1/messages/:id, so a handler that missed a webhook can read it back.
When it is not arriving
GET /v1/webhooks/deliveries shows what each attempt got back: the status code, the response body, and how long it took. That distinguishes an endpoint returning 500 from one that was never called, which is the first thing to establish.
curl "https://emails.sh/v1/webhooks/deliveries?webhook_id=wh_01J9X9B4T2&limit=10" \
-H "Authorization: Bearer $EMAILSSH_API_KEY"
# Send a test event now and see exactly what your endpoint answered
curl -X POST https://emails.sh/v1/webhooks/deliveries \
-H "Authorization: Bearer $EMAILSSH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"webhook_id": "wh_01J9X9B4T2"}'A failed attempt that still has attempts owed to it carries a retry object: next_attempt, next_attempt_at, attempts_made, and attempts_remaining. It hangs off the newest attempt at an event only, because an older row is a thing that already happened. It is null on anything that succeeded and on an event that has run out of attempts.
Abandoned events are in that log too, with no status code and the error that ended them, so a gap in what your endpoint received has a row explaining it rather than nothing at all.
Read, change, or remove one endpoint
An endpoint id addresses it directly. GET /v1/webhooks/:id reads one, PATCH changes it in place, and DELETE removes it along with anything held for it. The older PATCH /v1/webhooks?id= and DELETE /v1/webhooks?id= spellings still work and run the same code; the path form is the one to write now.
# One endpoint
curl https://emails.sh/v1/webhooks/wh_01J9X9B4T2 \
-H "Authorization: Bearer $EMAILSSH_API_KEY"
# Fix a URL and widen the subscription, keeping the id and the secret
curl -X PATCH https://emails.sh/v1/webhooks/wh_01J9X9B4T2 \
-H "Authorization: Bearer $EMAILSSH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://acme.com/hooks/emails/v2",
"events": ["email.sent", "email.delivered", "email.bounced", "email.complained"]
}'
# Rotate the signing secret. The new one is in this response and nowhere else
curl -X PATCH https://emails.sh/v1/webhooks/wh_01J9X9B4T2 \
-H "Authorization: Bearer $EMAILSSH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"rotate_secret": true}'
# Remove it
curl -X DELETE https://emails.sh/v1/webhooks/wh_01J9X9B4T2 \
-H "Authorization: Bearer $EMAILSSH_API_KEY"Fields you leave out are left alone, and events replaces the subscription wholesale rather than adding to it, so send the whole list you want. active: true turns a disabled endpoint back on and resets its failure count, because switching it back on is the acknowledgement that whatever broke is fixed. A GET never returns the secret, at any scope: it signs every delivery, and it is handed back only when it is new, once on create and once on a rotation. During the grace window after a rotation both secrets sign every delivery, and previous_secret_valid_until in the rotation response is the deadline to redeploy your receiver by.
An id that is not on this workspace answers 404 rather than 403, whether it never existed or belongs to somebody else, because a 403 would confirm the id is real elsewhere. A PATCH body that changes nothing is 400 nothing_to_update.
webhooks.list
Registered endpoints and the events each is subscribed to. Signing secrets are not listed.
Takes no arguments.
Returns{ webhooks: Webhook[] }
webhooks.create
Register an endpoint. The signing secret comes back once, in this response.
Arguments
urlstringrequired- An https endpoint of yours.
eventsstring[]- Defaults to email.delivered, email.bounced, and email.complained.
headersRecord<string, string>- Sent with every delivery, for a gateway that wants its own token.
Returns{ id, url, events, secret }
webhooks.get
One endpoint, with its events, whether it is active, and its failure counters. The signing secret is not in a read, at any scope.
Arguments
idstringrequired
ReturnsWebhook
webhooks.update
Change an endpoint in place. Fields you leave out are left alone.
Arguments
idstringrequiredurlstring- A new https endpoint. Fixing a typo here keeps the id and the secret.
eventsstring[]- Replaces the subscription wholesale rather than adding to it.
activeboolean- Turning it back on clears the failure count, so it does not trip on the next miss.
headersRecord<string, string>rotate_secretboolean- Mints a new signing secret and returns it once. The old one keeps signing for a grace window, so the receiver can be redeployed without dropping events.
ReturnsWebhook, plus secret and previous_secret_valid_until when rotate_secret was set
webhooks.deliveries
What each attempt got back: status code, response body, and duration. This is how you tell a broken endpoint from a missing event.
Arguments
webhook_idstringlimitnumber- Defaults to 25.
Returns{ deliveries: Delivery[] }
webhooks.delete
Remove an endpoint. Queued deliveries for it are dropped.
Arguments
idstringrequired
Returns{ deleted: id }