Email outbound Live
Resend, our outbound email service provider, posts delivery events back to us. We use them to update the per-message delivery status in the inbox and to manage the workspace suppression list (hard bounces, complaints, manual unsubscribes).
Endpoint
This endpoint is signed with the Svix signature scheme, which Resend uses for its webhook deliveries. We verify the HMAC plus a 5-minute replay window.
Configure your Resend webhook URL inside the Resend dashboard. We supply the signing secret on workspace provisioning; you do not paste it manually. If you operate your own Resend account, generate a secret there and store it in Settings → Integrations → Outbound email.
Required headers
Svix delivers three headers on every webhook. All three are required to verify the signature.
svix-id: msg_2N8Y8s7tEZ
svix-timestamp: 1715616000
svix-signature: v1,K1Q9rN... v1,oth3rSig...svix-id, unique delivery identifier. We dedupe on this within a 24-hour window.svix-timestamp, unix seconds. Requests older than 5 minutes are rejected as potential replays.svix-signature, space-separated list of valid signatures. During secret rotation, multiple signatures are present so old and new are accepted simultaneously.
Verification details (and code samples) at Signature verification.
Events we process
| Event | Action |
|---|---|
email.delivered | Update messages.delivery_status to delivered. |
email.bounced (Permanent) | Suppress immediately with reason hard_bounce. |
email.bounced (Transient) | Increment a soft-bounce counter. Suppress at three with reason soft_bounce_exceeded. |
email.complained | Suppress with reason complaint. Notify workspace admins. |
Other Resend events (email.sent, email.opened, email.clicked) are accepted but not currently mapped to internal state changes.
email.bounced payload
{
"type": "email.bounced",
"created_at": "2026-05-13T15:30:00Z",
"data": {
"email_id": "re_abc123",
"to": ["[email protected]"],
"from": "[email protected]",
"subject": "Re: Your refund",
"bounce": {
"type": "Permanent" | "Transient",
"subType": "General" | "MailboxFull" | "MessageTooLarge" | ...,
"diagnosticCode": "smtp; 550 5.1.1 mailbox does not exist"
}
}
}Permanent bounces (mailbox does not exist, domain not found) suppress immediately. Transient bounces (mailbox full, greylisting) increment a counter; we suppress at three in a 30-day window. This protects sender reputation without over-aggressively cutting off recoverable addresses.
email.complained payload
{
"type": "email.complained",
"created_at": "2026-05-13T15:30:00Z",
"data": {
"email_id": "re_abc123",
"to": ["[email protected]"]
}
}Complaints (the recipient hit the spam button) immediately suppress the address with reason complaint. Complaints carry strong signal: we never send to a complained address again without an explicit manual unblock in Suppressions API.
email.delivered payload
{
"type": "email.delivered",
"created_at": "2026-05-13T15:30:00Z",
"data": {
"email_id": "re_abc123",
"to": ["[email protected]"]
}
}Delivered events flip delivery_status on the message from sent to delivered. The inbox shows a small “delivered” tick when this fires. If you never see deliveries, your Resend webhook is misconfigured.
Suppression flow
Suppression entries created by webhook events are visible in the dashboard at Settings → Suppressions and via Suppressions API. Admins can manually remove an address (for example, after the customer asks to be re-added). The audit log captures every change. See Audit log.
Response codes
200 OK, event accepted (or duplicate redelivery; idempotent).401 invalid_signature, Svix signature did not match. Resend will retry.400 invalid_body, payload did not match expected shape.
Common pitfalls
- Verifying the wrong body. The signature is over the raw bytes plus
svix-id.svix-timestamp.bodyjoined by dots. Verifying parsed JSON breaks because property order varies. - Ignoring transient bounces. A single transient bounce is fine. Three within 30 days suppresses. If you see a customer suddenly stop receiving mail, check the soft-bounce counter before assuming a hard failure.
- Trusting
email.deliveredas proof of read. It only means the receiving MTA accepted the message. The user may still never open it. Use open / click events if you need that. - Stale secrets after rotation. Rotate via the dashboard; we accept the old and new secret simultaneously for 24 hours to bridge in-flight deliveries.