Mark the message as sent against your record
Email webhooks for every delivery event.
tinyRelay email webhooks post a signed event to your endpoint each time an email is sent, delivered, bounced, opened, clicked, unsubscribed or marked as spam. Every event carries your own metadata, so your app knows exactly which order, user or tenant it belongs to.
- SENDann@example.com · order_id SO-1001signed ✓
- DELIVEREDann@example.com · order_id SO-1001signed ✓
- OPENann@example.com · order_id SO-1001signed ✓
- CLICKurl: acme.com/orders/SO-1001signed ✓
- BOUNCEDbob@example.com · SOFT_BOUNCED · 22signed ✓
- SENDann@example.com · order_id SO-1001signed ✓
- DELIVEREDann@example.com · order_id SO-1001signed ✓
- SENDbob@example.com · order_id SO-1002signed ✓
- BOUNCEDbob@example.com · SOFT_BOUNCED · 22signed ✓
- BOUNCEDcy@example.com · HARD_BOUNCED · 10signed ✓
- OPENann@example.com · order_id SO-1001signed ✓
- CLICKurl: acme.com/orders/SO-1001signed ✓
- OPENdee@example.com · order_id SO-1004signed ✓
- CLICKurl: acme.com/accountsigned ✓
- UNSUBSCRIBEann@example.com · order_id SO-1001signed ✓
- SPAMeve@example.com · tenant_id acmesigned ✓
Signed headers: svix-id · svix-timestamp · svix-signature
Illustrative events; names and headers from the docs.
Email delivery events, one card each.
Seven event types cover the life of a transactional email. Here is what triggers each one and what your app usually does with it.
Confirm delivery, for example in a support view
Suppress on hard bounces, retry or investigate soft ones
Record engagement, if tracking is on
Tell apart clicks on different links in one email
Update the user's email preferences
Stop optional email to that address and review the content
Every event carries the IDs you need to match it to your own records.
Attach metadata when you send →The JSON each webhook event posts.
Pick an event to see the body your endpoint receives. The explorer steps through all seven until you take over.
{
"eventType": "SEND",
"notificationId": "5f0c6a2e-8d1b-4f7a-9c3e-2b6d1e4a7f90",
"receiver": "ann@example.com",
"requestId": "833c5151-5a98-4bad-a30f-7cc816352b7a",
"campaignId": "order_shipped_SO-1001",
"metadata": { "order_id": "SO-1001", "tenant_id": "acme" },
"requestTime": "1761912000123",
"eventTime": 1761912000123
}{
"eventType": "DELIVERED",
"notificationId": "b7e1d9c4-3a52-4e08-8f6b-91c2d0a5e3f1",
"receiver": "ann@example.com",
"requestId": "833c5151-5a98-4bad-a30f-7cc816352b7a",
"campaignId": "order_shipped_SO-1001",
"metadata": { "order_id": "SO-1001", "tenant_id": "acme" },
"requestTime": "1761912001342",
"eventTime": 1761912001342
}{
"eventType": "BOUNCED",
"subType": "SOFT_BOUNCED",
"notificationId": "0d4f8a61-27c3-4b9e-a5d2-6e1f3c8b7a04",
"receiver": "bob@example.com",
"requestId": "833c5151-5a98-4bad-a30f-7cc816352b7a",
"campaignId": "order_shipped_SO-1001",
"metadata": { "order_id": "SO-1002", "tenant_id": "acme" },
"bounceCategory": "22",
"bounceDescription": "[receiving server's diagnostic text]",
"bounceStatus": "[DSN status code]",
"requestTime": "1761912001410",
"eventTime": 1761912001410
}{
"eventType": "OPEN",
"notificationId": "9a3c5e17-f2b8-4d60-8c41-7b0e2d9f6a35",
"receiver": "ann@example.com",
"requestId": "833c5151-5a98-4bad-a30f-7cc816352b7a",
"campaignId": "order_shipped_SO-1001",
"metadata": { "order_id": "SO-1001", "tenant_id": "acme" },
"requestTime": "1761912184550",
"eventTime": 1761912184550
}{
"eventType": "CLICK",
"notificationId": "c61e0b28-5d7f-4a93-b1e4-38f2a9c0d756",
"receiver": "ann@example.com",
"requestId": "833c5151-5a98-4bad-a30f-7cc816352b7a",
"campaignId": "order_shipped_SO-1001",
"metadata": { "order_id": "SO-1001", "tenant_id": "acme" },
"url": "https://acme.com/orders/SO-1001?ref=email",
"requestTime": "1761912190872",
"eventTime": 1761912190872
}{
"eventType": "UNSUBSCRIBE",
"notificationId": "e2a7f4c9-1b06-4d38-9e5a-c4d1b8f2063e",
"receiver": "ann@example.com",
"requestId": "833c5151-5a98-4bad-a30f-7cc816352b7a",
"campaignId": "order_shipped_SO-1001",
"metadata": { "order_id": "SO-1001", "tenant_id": "acme" },
"requestTime": "1761912305218",
"eventTime": 1761912305218
}{
"eventType": "SPAM",
"notificationId": "7f9b2d40-6c15-4e8a-a3f7-0d5e1c9b4a82",
"receiver": "ann@example.com",
"requestId": "833c5151-5a98-4bad-a30f-7cc816352b7a",
"campaignId": "order_shipped_SO-1001",
"metadata": { "order_id": "SO-1001", "tenant_id": "acme" },
"requestTime": "1761912602004",
"eventTime": 1761912602004
}Field names from the webhook docs; values illustrative. The docs do not say whether bounce fields appear on other event types, so parse optional fields defensively.
Every field in the payload.
One JSON body per event. Two time fields, because the time an event happened and the time it reached us are not always the same.
| Field | What it holds |
|---|---|
eventType | What it holdsSEND, DELIVERED, BOUNCED, OPEN, CLICK, UNSUBSCRIBE or SPAM |
subType | What it holdsHARD_BOUNCED or SOFT_BOUNCED on bounces |
notificationId | What it holdsA UUID for each webhook request |
receiver | What it holdsThe recipient's email address |
requestId | What it holdsThe ID returned by the send request |
campaignId | What it holdsYour campaignId, or the requestId if you did not set one |
metadata | What it holdsRequest-level metadata merged with this recipient's own; always present, empty object if none |
requestTime | What it holdsEpoch milliseconds as a string; falls back to processing time, so never empty |
eventTime | What it holdsEpoch milliseconds (int64) when the event happened, or null if unknown |
bounceCategory | What it holdsOne of 19 documented bounce categories |
bounceDescription | What it holdsThe receiving server's diagnostic text |
bounceStatus | What it holdsThe receiving server's DSN status code |
url | What it holdsCLICK only: the link clicked, query string included; the key is left out on other events |
What we promise about each event, and for how long.
Drag through an event's life on the left. Check that a request is really from us on the right.
The event is signed with your endpoint's secret and sent with svix-id, svix-timestamp and svix-signature headers. Delivery is at least once, so design your handler to be safe if it sees an event twice.
If your endpoint times out or returns a 5xx, the event is retried. Every retry repeats the same svix-id, so deduplicate your processing on that ID rather than treating each request as new.
Each event gets an identifier built from its recipient, type and the time it happened. Within 24 hours, a repeat of an event you were already sent is dropped. A real second open or click happens at a different time, so it still reaches you.
After the window, the same event re-processed could reach you again. Size your own deduplication store with that in mind. An event missing its time, recipient or type is not deduplicated at all: a duplicate you can discard beats a dropped event you cannot recover.
Event payloads are kept for 5 days. A missed event can be redelivered within that window; after it, the payload is gone. Retention does not affect normal delivery or retries.
Verify a webhook signature
Run the three checks your handler should make on every request.
Animated walkthrough; nothing is sent.
Verify webhook signature headers.
Every request is signed. Verify it before you trust the payload: allowlisting our sending IP is not authentication, because anything that can reach your endpoint can post to it.
- Sign over the raw body exactly as received, not re-serialised JSON
- During a secret rotation, two signatures are listed; accept if any verifies
- The official Svix libraries do all of this for you
The delivery ID. Stable across retries of the same event; deduplicate on it.
When the delivery was signed, as a Unix timestamp in seconds.
One or more space-separated signatures, each written as v1,<base64>.
Header values illustrative.
One secret per endpoint
Each endpoint has its own signing secret, never shared across accounts. You receive it when your endpoint is configured. To rotate it, contact support.
One sending IP
Webhooks come from 3.229.12.181. Allow it through your firewall, then verify the signature anyway. More on security.
A bounce webhook that says why.
Each BOUNCED event tells you two things. subType says whether to keep sending. bounceCategory says what went wrong, as one of 19 documented categories.
Webhook questions.
What developers ask before they write the handler.
Send with the transactional email API →Seven: SEND, DELIVERED, BOUNCED, OPEN, CLICK, UNSUBSCRIBE and SPAM. BOUNCED events carry a subType of HARD_BOUNCED or SOFT_BOUNCED, and CLICK events carry the url that was clicked.
Compute an HMAC-SHA256 over {svix-id}.{svix-timestamp}.{body} with your endpoint's signing secret, using the raw request body, and compare it in constant time with each signature in svix-signature. The official Svix libraries do this for you.
Webhooks are sent from 3.229.12.181. Allow it through your firewall, but still verify the signature: allowlisting an IP is not authentication.
Delivery is at least once. If your endpoint times out or returns a 5xx, we retry with the same svix-id. Event payloads are kept for 5 days, so a missed event can be redelivered within that window.
Yes, so deduplicate on svix-id. We suppress repeats of the same event for 24 hours before they leave us, but a genuine second open or click is a new event and still reaches you.
Attach metadata when you send. Up to 10 keys come back on every event for that send, merged with any per-recipient metadata.
Send transactional email with a service that reports back.
SMTP relay or REST API, standalone or alongside tinyEmail. Tell us what you send and our team sets you up.