Webhooks
Events we send to your endpoints, and the provider webhooks we receive.
Webhooks to your endpoints
Subscribe an HTTPS endpoint with POST /api/v1/webhooks
and we POST each email event to it as it happens — email.delivered,
email.bounced, email.clicked and the rest. Every event type has its own
payload schema in the OpenAPI spec under webhooks, and the
SDKs return them as one typed union discriminated on type.
Deliveries are signed with Standard Webhooks
(webhook-id, webhook-timestamp, webhook-signature), at-least-once and
unordered: dedupe on webhook-id, order on occurred_at. Verify against the
raw body:
const event = sendandretain.webhooks.verify({
payload: rawBody,
headers: req.headers,
secret: process.env.SENDANDRETAIN_WEBHOOK_SECRET!,
});
if (event.type === "email.bounced") suppressInYourApp(event.data.message?.to);The Receive webhooks guide walks through it end to end, including secret rotation and replaying failed deliveries.
Provider webhooks we receive
Separately, Send & Retain receives webhooks from your email provider (Resend or SendGrid) to track delivery, opens, clicks, bounces, and complaints. You don't build these endpoints — they're part of the platform. This page explains what they do so you can reason about message status and suppression.
Each project has its own webhook URL and signing secret, registered when you connect a provider:
https://sendandretain.com/api/webhooks/resend/{projectId}
https://sendandretain.com/api/webhooks/sendgrid/{projectId}Verification
Every webhook is verified against the raw request body before it's trusted.
An unknown project id returns 404; a bad signature returns 401.
Resend signs with Svix. The signature is an HMAC-SHA256 over
{svix-id}.{svix-timestamp}.{body}, keyed with the project's whsec_…
secret, sent in svix-id, svix-timestamp, and svix-signature headers.
Timestamps outside a 5-minute window are rejected. Events are de-duplicated
on svix-id.
Event types
Provider events are normalized to a common set. The most important mappings:
| Normalized | Resend | SendGrid |
|---|---|---|
sent | email.sent | — |
delivered | email.delivered | delivered |
delivery_delayed | email.delivery_delayed | deferred |
opened | email.opened | open |
clicked | email.clicked | click |
bounced | email.bounced | bounce, blocked (soft) |
complained | email.complained | spamreport |
failed | email.failed | dropped |
canceled | email.canceled | — |
unsubscribed | — | unsubscribe |
Provider events outside this set (Resend email.scheduled, SendGrid
processed) are ignored rather than recorded.
Status ladder
A message's status only ever moves forward along this ladder, so out-of-order webhooks can't regress it:
queued → scheduled → sending → sent → delivered → bounced → complainedfailed, canceled, and suppressed are terminal. opened, clicked,
unsubscribed, and delivery_delayed are recorded on the timeline but don't
change the status. You can read the full timeline via
GET /api/v1/emails/{id}.
Auto-suppression
Hard bounces and complaints automatically add the recipient to the project's suppression list, so you can't keep sending to an address that bounced or reported spam:
- A complaint suppresses the recipient (reason
complaint) — and complaint suppressions are never removable. - A permanent bounce suppresses the recipient (reason
bounce). - SendGrid
unsubscribeevents also suppress (reasonunsubscribe).
Suppression reasons have a severity order — complaint > bounce > manual >
unsubscribe — so a stronger reason is never downgraded by a weaker one.