After sending
More ways to use this page
Webhooks
A webhook is an HTTPS URL of yours that Sendora posts events to as they happen: a delivery, a bounce, a complaint, a received message. This page shows how to register one, check that each request comes from Sendora, and recover the deliveries that failed.
Rules for your endpoint
- Verify the signature against the raw body before you parse it. The steps are below.
- Answer with any 2xx status, then do your work. Any other status fails the attempt, a redirect included, and so does no answer within the timeout on Limits.
- Expect the same delivery more than once, and key your handling on its
id. - Expect deliveries out of order. Sendora attempts them in order for each webhook, but a retried delivery can arrive after later ones. To order a message’s events, compare their
occurredAt.
Register
curl -X POST https://api.sendora.se/v1/webhooks \
-H "Authorization: Bearer $SENDORA_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.se/hooks/sendora",
"events": [
"delivered",
"bounced",
"deferred",
"spam_complaint"
]
}'import { Sendora } from '@sendora/sdk';
const sendora = new Sendora({
token: process.env.SENDORA_API_TOKEN,
});
const { webhookId, secret } =
await sendora.webhooks.create({
url: 'https://example.se/hooks/sendora',
events: [
'delivered',
'bounced',
'deferred',
'spam_complaint',
],
});
console.log(webhookId);
console.log(secret); // shown this once: store it beside the token
import os
from sendora import Sendora
sendora = Sendora(os.environ["SENDORA_API_TOKEN"])
webhook = sendora.webhooks.create(
url="https://example.se/hooks/sendora",
events=[
"delivered",
"bounced",
"deferred",
"spam_complaint",
],
)
print(webhook.webhook_id)
# Shown this once: store it beside the token.
print(webhook.secret)
Response (21 lines)
{
"webhookId": "7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70",
"url": "https://example.se/hooks/sendora",
"events": [
"delivered",
"bounced",
"deferred",
"spam_complaint"
],
"streamId": null,
"enabled": true,
"inboundContent": "full",
"createdAt": "2026-09-15T12:00:00.000Z",
"secrets": [
{
"secretId": "4a5b6c7d-8e9f-4a0b-8c1d-2e3f4a5b6c7d",
"createdAt": "2026-09-15T12:00:00.000Z"
}
],
"secret": "whsec_9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a"
}The response includes secret once: the webhook’s first signing secret. Store it with your other secrets.
- The URL must use HTTPS and point to a public host. If it resolves to a private address, each delivery fails at once and becomes dead, without retries.
- A server has one webhook per URL. Registering a URL again fails with
webhook_, which includes the existing webhook’s id.exists - Leave out
eventsto receive every event. A webhook’s events cannot be changed. Instead, remove the webhook and create it again, which gives it a new secret. Events in between are not delivered. - With
streamId, the webhook receives the message events of that one stream. Cap events belong to no stream, so they reach the webhook either way. inboundContentdecides how much of a received message theinboundevent carries, as Inbound explains.
You can switch a webhook off in the dashboard. While it is off, its enabled is false, Sendora queues no events for it, and its pending deliveries become dead.
Events
Each delivery is one event, posted as JSON. The body has the event’s name in event, the delivery’s id and attempt, and the event’s own fields. Webhook events lists every field of every event, with an example of each. Each event’s name below links to its fields.
| Event | Sent when |
|---|---|
delivered | The receiving server accepted the message for the recipient. |
bounced | The receiving server refused the message, or Sendora gave up retrying. After a hard bounce, the recipient is on the stream’s suppression list. |
deferred | The receiving server asked Sendora to try later. Sendora keeps trying, so several may follow. |
spam_ | The recipient reported the message as spam. The recipient is suppressed. |
unsubscribed | The recipient unsubscribed from a broadcast stream and is suppressed on it. The event comes a minute after the click, since the recipient can undo it for that long. |
cap_ | The server or the account has used most of its monthly cap, at the share on Limits. |
cap_ | A send was refused because the monthly cap is used up. |
inbound | A message arrived on an inbound stream. |
Only Sendora support can lift a spam complaint or an unsubscribe, as Suppressions explains. When you get unsubscribed, take the recipient off your own list too.
A message event has test set to true when the message was on a test server. Its events were simulated, and nothing was sent.
Verify the signature
Every request carries Sendora-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]. To verify it:
- Read
tand everyv1from the header. There is onev1for each live secret of the webhook, the newest first. - Compute an HMAC-SHA256 of
<t>in hex. Its key is your whole secret,.<raw body> whsec_included. - Compare it with each
v1in constant time. One match is enough. - Refuse the request if
tis more than five minutes from your clock, so a captured request cannot be replayed.
Only then parse the body. Sendora signs each attempt as it sends it, so a retry or a replay carries a new t and passes step 4.
The sample shows the steps in shell, and the one call that does all four in each SDK:
# Sendora-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]
# One v1 per live secret of the webhook, the newest first; each is
# HMAC-SHA256 with that secret over "<t>.<raw body>".
expected=$(printf '%s.%s' "$T" "$RAW_BODY" | openssl dgst -sha256 -hmac "$SENDORA_WEBHOOK_SECRET" | sed 's/^.* //')
# Compare in constant time in your language, refuse a t more than five minutes from your clock, then parse the body.
# One matching v1 is enough; $V1S holds every v1 of the header, one per line.
printf '%s\n' "$V1S" | grep -qx "$expected" && echo "signed by Sendora"
import { verifyWebhook } from '@sendora/sdk';
/*
* A route handler for any framework that hands you a Fetch Request:
* Next.js, Hono, SvelteKit, Cloudflare Workers. With Express or Fastify,
* keep the raw body and pass it and the header the same way.
*/
export async function POST(
request: Request,
): Promise<Response> {
const event = await verifyWebhook({
secret: process.env.SENDORA_WEBHOOK_SECRET,
signature: request.headers.get('sendora-signature'),
body: await request.text(),
});
if (event.event === 'bounced' && event.details.hard) {
console.log(
`${event.recipient} is undeliverable: ${event.details.classification ?? 'unknown'}`,
);
}
return new Response(null, { status: 204 });
}
import os
from collections.abc import Mapping
from sendora import (
BouncedEvent,
WebhookVerificationError,
verify_webhook,
)
# A handler for any framework: give it the raw body
# exactly as it arrived and the request's headers.
def receive(
body: bytes, headers: Mapping[str, str]
) -> int:
# Header names ignore case, and a plain dict does not.
lowered = {
name.lower(): value
for name, value in headers.items()
}
try:
event = verify_webhook(
body,
signature=lowered.get("sendora-signature"),
secret=os.environ["SENDORA_WEBHOOK_SECRET"],
)
except WebhookVerificationError:
return 400
if (
isinstance(event, BouncedEvent)
and event.details.hard
):
print(
f"{event.recipient} is undeliverable: "
f"{event.details.classification or 'unknown'}"
)
return 204
verifyWebhook returns the event, typed by its event field, so details has the right type. An event newer than your release of the SDK comes as an unknown event, with its name in name and the payload in data, rather than an error. It throws stale_ when the signature is more than five minutes old, and invalid_ when no signature in the header matches. Set toleranceSeconds to allow another age. With a client at hand, sendora does the same from a Fetch Request in one call.
verify_ returns the event as a class of its own, such as BouncedEvent, so its details are typed. An event newer than your release of the SDK comes as an UnknownEvent, with the payload in data, rather than an error. It raises WebhookVerificationError with stale_ when the signature is more than five minutes old, and with invalid_ when no signature in the header matches. Pass tolerance, in seconds, to allow another age.
The raw body is request in Flask and await request in FastAPI. In Django, read it with request, since request refuses a body over DATA_, 2.5 MB by default. An inbound event with its content can be larger, as Limits shows.
Pass the body and request to receive, and answer with the status it returns. In Flask, pass dict(, since type checkers do not take Werkzeug’s headers as a Mapping. Exempt the Django view from the CSRF check with csrf_, since Sendora sends no CSRF token.
Roll the secret
A webhook can have two live secrets at once, so you can replace one without a gap:
- Create a new secret. From then on, every delivery is signed with both.
curl -X POST https://api.sendora.se/v1/webhooks/{webhookId}/secrets \
-H "Authorization: Bearer $SENDORA_API_TOKEN"import { Sendora } from '@sendora/sdk';
const sendora = new Sendora({
token: process.env.SENDORA_API_TOKEN,
});
const rolled = await sendora.webhooks.createSecret(
'7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',
);
console.log(rolled.secretId, rolled.secret);
import os
from sendora import Sendora
sendora = Sendora(os.environ["SENDORA_API_TOKEN"])
rolled = sendora.webhooks.create_secret(
"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70"
)
print(rolled.secret_id, rolled.secret)
Response
{
"secretId": "5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e",
"createdAt": "2026-09-20T12:00:00.000Z",
"secret": "whsec_0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f"
}- Move your receiver to the new secret. Deliveries verify with either one meanwhile.
- Delete the old secret by its id, which the webhook’s
secretslists.
curl -X DELETE https://api.sendora.se/v1/webhooks/{webhookId}/secrets/{secretId} \
-H "Authorization: Bearer $SENDORA_API_TOKEN"import { Sendora } from '@sendora/sdk';
const sendora = new Sendora({
token: process.env.SENDORA_API_TOKEN,
});
await sendora.webhooks.deleteSecret(
'7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',
'4a5b6c7d-8e9f-4a0b-8c1d-2e3f4a5b6c7d',
);
import os
from sendora import Sendora
sendora = Sendora(os.environ["SENDORA_API_TOKEN"])
sendora.webhooks.delete_secret(
"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70",
"4a5b6c7d-8e9f-4a0b-8c1d-2e3f4a5b6c7d",
)
A third live secret fails with secret_. Deleting the last live secret fails with last_, so every delivery carries a signature. You can also roll the secret on the webhook’s page in the dashboard, under the server’s Webhooks tab.
Retries, dead letters and replay
Sendora retries a failed attempt with growing delays, for about a day, on the schedule on Limits. After the last retry, the delivery is dead. It stays listed until you replay it.
curl 'https://api.sendora.se/v1/webhooks/{webhookId}/deliveries?status=dead&limit=50' \
-H "Authorization: Bearer $SENDORA_API_TOKEN"import { Sendora } from '@sendora/sdk';
const sendora = new Sendora({
token: process.env.SENDORA_API_TOKEN,
});
const { deliveries } = await sendora.webhooks.deliveries(
'7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',
{
status: 'dead',
limit: 50,
},
);
for (const delivery of deliveries) {
console.log(
delivery.deliveryId,
delivery.event,
delivery.attempts,
delivery.lastError,
);
}
import os
from sendora import Sendora
sendora = Sendora(os.environ["SENDORA_API_TOKEN"])
page = sendora.webhooks.deliveries(
"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70",
status="dead",
limit=50,
)
for delivery in page.deliveries:
print(
delivery.delivery_id,
delivery.event,
delivery.attempts,
delivery.last_error,
)
Response (18 lines)
{
"deliveries": [
{
"deliveryId": "9e8d7c6b-5a4f-4e3d-9c2b-1a0f9e8d7c60",
"event": "bounced",
"messageId": "5f432ffd-c005-499b-aa3a-5b8a088ea20e",
"inboundMessageId": null,
"status": "dead",
"attempts": 9,
"nextAttemptAt": null,
"lastStatusCode": 503,
"lastError": "http_503",
"deliveredAt": null,
"createdAt": "2026-09-14T12:00:02.000Z"
}
],
"next": null
}A replay queues a delivery again, whatever its state, with the same id and event. Its attempt keeps counting. The reference has the call.
Remove
curl -X DELETE https://api.sendora.se/v1/webhooks/{webhookId} \
-H "Authorization: Bearer $SENDORA_API_TOKEN"import { Sendora } from '@sendora/sdk';
const sendora = new Sendora({
token: process.env.SENDORA_API_TOKEN,
});
await sendora.webhooks.delete(
'7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',
);
import os
from sendora import Sendora
sendora = Sendora(os.environ["SENDORA_API_TOKEN"])
sendora.webhooks.delete(
"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70"
)
Deliveries still pending for the webhook are dropped with it.