docs

API

Webhooks

Get told when a message is delivered, delayed, bounces or is reported as spam, and verify it is really from emitemail.

Add an endpoint under Webhooks and emitemail will call it when something happens to a message. Use it to mark a bad address, update an order, or keep your own delivery log.

Events#

Event When
email.delivered The receiving server accepted the message.
email.deferred The receiving server asked us to try again later. emitemail keeps retrying, so this is a heads-up, not a failure. You get it once per message.
email.bounced The message could not be delivered. data.bounce_type is hard (the address is bad), soft, blocked (the receiver refused our server or content) or unknown.
email.complained The recipient reported the message as spam.

Choose which events each endpoint receives when you add it. Problems only is bounces and complaints; Everything adds delivered and deferred.

The request#

emitemail sends a POST with a JSON body and two headers: emit-event-id (unique per event, use it to ignore duplicates) and emit-signature.

POST https://your-app.example/hooks/emit
emit-signature: t=1791066711,v1=<hex>
emit-event-id: evt_8fK2...

{
  "id": "evt_8fK2...",
  "api_version": "2026-10-07",
  "type": "email.bounced",
  "created_at": "2026-10-07T22:31:51Z",
  "data": {
    "message_id": "emt_kS1L...",
    "to": "gone@example.com",
    "from": "orders@yourshop.com",
    "subject": "Your order is confirmed",
    "stream": "transactional",
    "bounce_type": "hard",
    "detail": "550 5.1.1 User unknown",
    "tags": [{ "name": "order_id", "value": "1042" }]
  }
}

bounce_type appears only on bounces. tags appears only if you set tags when sending, which makes it easy to match an event to your own records. Treat unknown fields as additions and ignore them, so your code keeps working when we add more.

Events from the Send test button have "test": true. Do not act on them as real mail.

Respond and retries#

Answer with a 2xx within 5 seconds. Do the real work after you have replied.

If your endpoint is slow, errors or is down, emitemail retries after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours. The same event keeps the same id on every attempt, so a retry can arrive twice. Use emit-event-id to skip one you have already handled.

Open an endpoint to see its deliveries. Each shows the status code your server returned and the first 500 characters of its response, so you can see why one failed.

If an endpoint fails 10 deliveries in a row, emitemail turns it off and emails the account owner. Fix it, switch it back on, and new events flow again.

Replay an event#

Missed something, for example while an endpoint was off? Choose Replay on a delivery (Retry if it failed). It is sent again with the same id, so your duplicate check still works.

Rotate the secret#

Choose Rotate secret on an endpoint to get a new signing secret. The old one stops working straight away, so update your app first or deploy both at once.

Verify the signature#

Always check the signature on the raw request body before trusting a webhook. The signature is an HMAC-SHA256 of timestamp.body using your endpoint's secret.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret, rawBody, header) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  const ok =
    parts.v1.length === expected.length &&
    timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  return ok && fresh;
}

The timestamp check stops someone replaying an old request.