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:

NormalizedResendSendGrid
sentemail.sent—
deliveredemail.delivereddelivered
delivery_delayedemail.delivery_delayeddeferred
openedemail.openedopen
clickedemail.clickedclick
bouncedemail.bouncedbounce, blocked (soft)
complainedemail.complainedspamreport
failedemail.faileddropped
canceledemail.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 → complained

failed, 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 unsubscribe events also suppress (reason unsubscribe).

Suppression reasons have a severity order — complaint > bounce > manual > unsubscribe — so a stronger reason is never downgraded by a weaker one.

On this page