Webhooks
How Send & Retain receives delivery, bounce, and complaint events from your provider.
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.
SendGrid signs with ECDSA (P-256 / SHA-256) over
{timestamp}{body}, using the project's public key. The signature is in
X-Twilio-Email-Event-Webhook-Signature and the timestamp in
X-Twilio-Email-Event-Webhook-Timestamp. A SendGrid payload is a JSON array
of events (batched, possibly out of order); events are de-duplicated on
sg_event_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.