Skip to content

Webhooks & events

A 202 from the send endpoint means accepted, not delivered. Everything that happens afterwards reaches you here.

Register an endpoint

POST/v1/webhooks
request · json
{
  "url": "https://yourapp.com/hooks/sreemail",
  "events": ["email.bounced", "email.complained"]
}

An empty events array subscribes to everything. The response contains a secret, returned once, that signs every delivery to this endpoint. URLs must be HTTPS.

Event shape

json
{
  "id": "evt_use1_01JQ…",
  "type": "email.bounced",
  "createdAt": "2026-07-26T10:31:02.000Z",
  "data": {
    "messageId": "msg_use1_01JQ…",
    "to": ["user@example.com"],
    "bounceType": "permanent",
    "reason": "550 5.1.1 user unknown",
    "tags": { "campaign": "receipts" }
  }
}

Event types

TypeMeaning
email.sentHanded to the receiving mail server.
email.deliveredAccepted by the recipient's server.
email.bouncedRejected. Permanent bounces suppress the address.
email.complainedMarked as spam. Always suppresses the address.
email.delayedTemporary failure; delivery is still being retried.
email.rejectedRefused before sending, usually for content.
email.failedCould not be sent, e.g. a template error or exhausted retries.
email.openedTracking pixel loaded. Opt-in per domain.
email.clickedA rewritten link was followed. Opt-in per domain.
domain.verifiedA sending domain finished verification.
domain.verification_failedA domain was rejected or timed out.

Note

Only permanent bounces suppress an address. A full mailbox or a greylist produces email.delayed or a transient bounce and is retried — suppressing on those would blacklist recipients for a temporary condition.

Verifying signatures

Every delivery carries sreemail-signature, formatted t={unix},v1={hex}. The value is an HMAC-SHA256 of {timestamp}.{raw body} keyed with your endpoint secret.

verify.ts · typescript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody: string, header: string, secret: string) {
  const parts = new Map(header.split(",").map((p) => p.split("=") as [string, string]));
  const t = Number(parts.get("t"));

  // Reject replays. The timestamp is inside the signed
  // payload, so it cannot be swapped for a fresh one.
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;

  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest();

  const given = Buffer.from(parts.get("v1")!, "hex");
  return expected.length === given.length &&
    timingSafeEqual(expected, given);
}

Use the raw body

Verify against the exact bytes received, before any JSON parsing. Re-serialising changes key order and whitespace, and the signature will never match.

Retries

Return any 2xx to acknowledge. Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours.

A 4xx other than 429 stops the ladder immediately — your endpoint understood the request and refused it. After 20 consecutive failures the endpoint is disabled and the account owner is notified.

Replaying events

POST/v1/events/{id}/replay

Events outlive their delivery attempts. When a bad deploy takes your handler down, list what you missed and replay it rather than losing the mail.

GET/v1/events

Filter with messageId to see the full history of one message, or page through with limit.