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
| Event | When |
|---|---|
email.scheduled | An email was accepted for a later scheduled_at |
email.sent | SES accepted the email for delivery (one event per email, without recipient) |
email.delivered | The recipient's mail server accepted it |
email.delivery_delayed | The recipient's server is deferring it; SES keeps trying |
email.bounced | It bounced. bounce.type is Permanent, Transient or Undetermined |
email.complained | The recipient marked it as spam |
email.opened, email.clicked | With open or click tracking on for the domain |
email.rejected | SES refused it before sending, for example because of a virus |
email.failed | Sending failed; failed.reason says why (such as domain_sending_disabled) |
email.suppressed | Nothing was sent because every recipient is on your SES account's suppression list; suppressed.type is OnAccountSuppressionList |
email.received | Inbound mail arrived on a receiving domain |
domain.created, domain.updated, domain.deleted | A domain was added, changed (including its verification status) or removed |
contact.unsubscribed | A 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:
| Header | Value |
|---|---|
webhook-id | The delivery's id, the same on every retry: use it to drop duplicates |
webhook-timestamp | Unix seconds when this attempt was signed |
webhook-signature | v1,<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 wrongRetries
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.
Inbound and inboxes
Receive email with Amazon SES in your own AWS account, read it through Resend's received-email API, and run inboxes with threads, replies, drafts and agent keys.
Marketing email
Contacts, lists, segments, templates, campaigns, sequences and signup forms on Amazon SES in your own AWS account, with one-click unsubscribe built in.