WebhooksSigned through Svix

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.

POST → yourapp.com/webhooks/tinyrelayfrom 3.229.12.181
  1. SENDann@example.com · order_id SO-1001signed ✓
  2. DELIVEREDann@example.com · order_id SO-1001signed ✓
  3. OPENann@example.com · order_id SO-1001signed ✓
  4. CLICKurl: acme.com/orders/SO-1001signed ✓
  5. BOUNCEDbob@example.com · SOFT_BOUNCED · 22signed ✓
  1. SENDann@example.com · order_id SO-1001signed ✓
  2. DELIVEREDann@example.com · order_id SO-1001signed ✓
  3. SENDbob@example.com · order_id SO-1002signed ✓
  4. BOUNCEDbob@example.com · SOFT_BOUNCED · 22signed ✓
  5. BOUNCEDcy@example.com · HARD_BOUNCED · 10signed ✓
  1. OPENann@example.com · order_id SO-1001signed ✓
  2. CLICKurl: acme.com/orders/SO-1001signed ✓
  3. OPENdee@example.com · order_id SO-1004signed ✓
  4. CLICKurl: acme.com/accountsigned ✓
  1. UNSUBSCRIBEann@example.com · order_id SO-1001signed ✓
  2. SPAMeve@example.com · tenant_id acmesigned ✓

Signed headers: svix-id · svix-timestamp · svix-signature

Illustrative events; names and headers from the docs.

Event types

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.

SEND01
The email is sent successfully
What apps usually do

Mark the message as sent against your record

DELIVERED02
The email gets delivered
What apps usually do

Confirm delivery, for example in a support view

BOUNCED03
The email bounces; subType is HARD_BOUNCED or SOFT_BOUNCED
What apps usually do

Suppress on hard bounces, retry or investigate soft ones

OPEN04
The email is opened by the recipient
What apps usually do

Record engagement, if tracking is on

CLICK05
The recipient clicks a redirect link in the email; carries url
What apps usually do

Tell apart clicks on different links in one email

UNSUBSCRIBE06
The receiver clicks the unsubscribe link
What apps usually do

Update the user's email preferences

SPAM07
The email is marked as spam
What apps usually do

Stop optional email to that address and review the content

Event explorer

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.

Event explorer · pick an event to see its payloadContent-Type: application/json
SEND · email sent successfully
{
  "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
}
DELIVERED · email delivered
{
  "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
}
BOUNCED · subType HARD_BOUNCED or SOFT_BOUNCED
{
  "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
}
OPEN · opened by the recipient
{
  "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
}
CLICK · carries the url clicked
{
  "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
}
UNSUBSCRIBE · receiver clicked unsubscribe
{
  "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
}
SPAM · marked as spam
{
  "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.

Payload

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.

Ordering events? Use eventTime. Delivery is asynchronous and retried, so the moment your endpoint receives a request is not a reliable stand-in for when the event happened.
Webhook Responses reference →
FieldWhat it holds
eventTypeWhat it holdsSEND, DELIVERED, BOUNCED, OPEN, CLICK, UNSUBSCRIBE or SPAM
subTypeWhat it holdsHARD_BOUNCED or SOFT_BOUNCED on bounces
notificationIdWhat it holdsA UUID for each webhook request
receiverWhat it holdsThe recipient's email address
requestIdWhat it holdsThe ID returned by the send request
campaignIdWhat it holdsYour campaignId, or the requestId if you did not set one
metadataWhat it holdsRequest-level metadata merged with this recipient's own; always present, empty object if none
requestTimeWhat it holdsEpoch milliseconds as a string; falls back to processing time, so never empty
eventTimeWhat it holdsEpoch milliseconds (int64) when the event happened, or null if unknown
bounceCategoryWhat it holdsOne of 19 documented bounce categories
bounceDescriptionWhat it holdsThe receiving server's diagnostic text
bounceStatusWhat it holdsThe receiving server's DSN status code
urlWhat it holdsCLICK only: the link clicked, query string included; the key is left out on other events
Delivery semantics

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.

At least once
Signed and posted to your endpoint.

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.

Retries
We retry with the same delivery ID.

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.

24 h suppression
Repeats are dropped before they leave us.

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.

Your store
Beyond 24 hours, dedupe is yours.

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.

5-day replay
Missed events can be replayed.

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.

1
Read svix-id
Seen this ID already? Acknowledge it and skip processing.
2
Check svix-timestamp
Unix seconds. Reject anything outside your tolerance; five minutes is a reasonable default.
3
Match svix-signature
HMAC-SHA256 of id.timestamp.body with your secret, compared in constant time against each v1 signature listed.
Not checked yetVerify sample request

Animated walkthrough; nothing is sent.

Signatures

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
Request headersPOST · application/json
svix-idmsg_2Kx…

The delivery ID. Stable across retries of the same event; deduplicate on it.

svix-timestamp1761912000

When the delivery was signed, as a Unix timestamp in seconds.

svix-signaturev1,K5oZ…=

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.

Bounce webhook

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.

HARD_BOUNCED · 10
Bad email address. Add it to your own suppression list.
Stop
SOFT_BOUNCED · 22
Mailbox full. The address may well be valid.
Retry
SOFT_BOUNCED · 55
Refused on SPF, DKIM or DMARC policy. Check your domain authentication.
Investigate
FAQ

Webhook questions.

What developers ask before they write the handler.

Send with the transactional email API →
Which email delivery events does tinyRelay send?

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.

Get started

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.