Webhooks

Receive delivery, bounce, complaint, engagement, inbound, domain and unsubscribe events as signed Standard Webhooks, and verify the signature in Node.js or any language.

A webhook belongs to a project. It receives the events you subscribe to as POST requests to an HTTPS endpoint, signed with a secret only you and Stamp0 know.

Create a webhook

In the dashboard under Developers → Webhooks, or through the API:

curl https://api.stamp0.com/webhooks \
  -H "Authorization: Bearer $STAMP0_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "endpoint": "https://acme.co/hooks/stamp0", "events": ["email.delivered", "email.bounced", "email.received"] }'

The response carries the signing secret, whsec_…. Store it as STAMP0_WEBHOOK_SECRET. The endpoint must be a public HTTPS URL. As in Resend, GET /webhooks/:id returns the secret again (to full-access keys and to dashboard owners and admins), and POST /webhooks/:id/signing-secret/rotate replaces it: deliveries are signed with the new secret from then on.

Events

EventWhen
email.scheduledAn email was accepted for a later scheduled_at
email.sentSES accepted the email for delivery (one event per email, without recipient)
email.deliveredThe recipient's mail server accepted it
email.delivery_delayedThe recipient's server is deferring it; SES keeps trying
email.bouncedIt bounced. bounce.type is Permanent, Transient or Undetermined
email.complainedThe recipient marked it as spam
email.opened, email.clickedWith open or click tracking on for the domain
email.rejectedSES refused it before sending, for example because of a virus
email.failedSending failed; failed.reason says why (such as domain_sending_disabled)
email.suppressedNothing was sent because every recipient is on your SES account's suppression list; suppressed.type is OnAccountSuppressionList
email.receivedInbound mail arrived on a receiving domain
domain.created, domain.updated, domain.deletedA domain was added, changed (including its verification status) or removed
contact.unsubscribedA contact unsubscribed from one list or from all marketing email, by one-click, the unsubscribe page or the preference center. See Unsubscribe and compliance

Payload

Bodies follow Resend's event shape: type, created_at and data. Email events carry metadata only; bodies stay in your bucket.

{
  "type": "email.bounced",
  "created_at": "2026-10-19T14:02:11.803Z",
  "data": {
    "email_id": "em_7Hq2fX9aLp3Kd8Wz",
    "message_id": "<0100019a1c2b3d4e-5f6a7b8c-1d2e-4f3a-9b8c-7d6e5f4a3b2c-000000@email.amazonses.com>",
    "created_at": "2026-10-19T14:02:09.114Z",
    "from": "Acme <billing@acme.co>",
    "to": ["old@defunct.biz"],
    "subject": "Your invoice",
    "tags": { "category": "invoice" },
    "recipient": "old@defunct.biz",
    "bounce": { "type": "Permanent", "subType": "General", "message": "550 5.1.1 user unknown" }
  }
}

message_id is the RFC 5322 Message-ID SES gave the message, present once SES accepted it. email.received carries the sender, recipients, received_for (the addresses it was delivered for, including Bcc), subject, message_id, the inbox_ids and thread_ids it reached, SES's spam and virus verdicts, and attachment descriptors. Fetch the content with GET /emails/receiving/:id. Domain events carry the domain object as data. contact.unsubscribed carries contact_id, email, scope (list or all), list_id, source, source_id (the campaign or sequence whose link was used) and unsubscribed_at.

Verify the signature

Every delivery is signed with Standard Webhooks. Three headers come with it:

HeaderValue
webhook-idThe delivery's id, the same on every retry: use it to drop duplicates
webhook-timestampUnix seconds when this attempt was signed
webhook-signaturev1,<base64 HMAC-SHA256> of <webhook-id>.<webhook-timestamp>.<raw body>, keyed with the base64-decoded part of whsec_…

Verify against the raw request body, before any JSON parsing, and reject timestamps more than a few minutes old. The libraries below do both.

import { Resend } from 'resend';

const resend = new Resend(process.env.STAMP0_API_KEY, { baseUrl: 'https://api.stamp0.com' });

// rawBody: the request body as a string, exactly as received
const event = resend.webhooks.verify({
  payload: rawBody,
  headers: {
    id: req.headers['webhook-id'],
    timestamp: req.headers['webhook-timestamp'],
    signature: req.headers['webhook-signature'],
  },
  webhookSecret: process.env.STAMP0_WEBHOOK_SECRET,
}); // throws when the signature or timestamp is wrong

Retries

Answer with any 2xx within 15 seconds, and do slow work afterwards. Any other status, a timeout or a network error is retried with exponential backoff: 30 seconds, then doubling up to every 6 hours, for 11 attempts in all. A 410 Gone stops retries at once. Each retry carries the same webhook-id and a fresh timestamp and signature.

Reference: Create a webhook, Update a webhook.

On this page