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

POST /v1/webhooks
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"]
  }'
201 Created
{
  "id": "wh_01J9X9B4T2",
  "url": "https://acme.com/hooks/emails",
  "events": ["email.delivered", "email.bounced", "email.complained"],
  "secret": "whsec_2f8c1a4e9b7d0c53"
}

Events

EventWhen it fires
email.sentThe receiving mail server accepted the message from us.
email.deliveredThe receiving server confirmed delivery. Usually seconds after sent.
email.bouncedRefused. data carries bounce_type, bounce_subtype, and the remote diagnostic.
email.complainedThe recipient marked it as spam. The address is suppressed automatically. Arrives hours or days later.
email.receivedInbound 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.filteredInbound mail dropped by one of your rules before it was stored. Fires instead of email.received, not as well as it.
domain.verifiedA domain finished verification. The one event a setup script waits on.
workspace.throttledCold sending is being slowed. Clears on its own after a clean day.
workspace.pausedEvery send is refused until a person has looked.
workspace.resumedThe throttle cleared.
automation.run.startedAn automation run entered its first step.
automation.run.failedA 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

A delivery
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.

Node
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);
}
Python
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.

Eventsequence
email.received1
email.filtered1
email.sent2
email.delivered3
email.bounced3
email.complained4
anything with no message behind it0

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.

AttemptWait before itElapsed
1immediate0
25s5s
35m5m 5s
430m35m 5s
52h2h 35m 5s
65h7h 35m 5s
710h17h 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.

Deduplicate on the id
// 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.

email.received
{
  "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
      }
    ]
  }
}
FieldWhat it is
message_idThe stored message. Use it for GET /v1/messages/:id and for the attachment routes.
thread_idThe conversation, stable across the whole exchange. Reply in thread with POST /v1/messages/:id/reply-all.
mailboxThe address of yours it arrived at, which is how you route by product or by tenant.
fromThe envelope sender, bare.
from_nameThe display name off the From header, or null.
to, ccEvery address on the message, as arrays. bcc is not there because it never travels.
subjectAs sent, already decoded from any MIME encoding.
snippetThe first line or so of the text body, for a list view.
text, htmlBoth bodies, whichever the sender provided. Either can be null; a message with only HTML is common.
dateThe Date header as the sender wrote it. Not when we received it: timestamp on the envelope is that.
message_id_headerThe sender own Message-ID, for matching against records you already keep.
in_reply_to, referencesThe threading chain, verbatim. thread_id is our answer to the same question and is easier.
list_idSet on mail from a mailing list. Present is a strong reason not to auto-reply.
headersEvery header. Keys lowercased, repeats joined with a semicolon.
spam_verdictWhat the receiving filter thought.
authSPF, 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.
automatedA 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.
attachmentsfilename, 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_imagesImages 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.

Deliveries and a live test
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.

GET, PATCH, and DELETE /v1/webhooks/:id
# 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

url string required
An https endpoint of yours.
events string[]
Defaults to email.delivered, email.bounced, and email.complained.
headers Record<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

id string required

ReturnsWebhook

webhooks.update

Change an endpoint in place. Fields you leave out are left alone.

Arguments

id string required
url string
A new https endpoint. Fixing a typo here keeps the id and the secret.
events string[]
Replaces the subscription wholesale rather than adding to it.
active boolean
Turning it back on clears the failure count, so it does not trip on the next miss.
headers Record<string, string>
rotate_secret boolean
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_id string
limit number
Defaults to 25.

Returns{ deliveries: Delivery[] }

webhooks.delete

Remove an endpoint. Queued deliveries for it are dropped.

Arguments

id string required

Returns{ deleted: id }