# Webhooks

Events pushed to your endpoint, how to verify them, and retries and replays.

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](https://sendora.se/docs/limits#webhooks).
- 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:

```bash
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"
  ]
}'
```

TypeScript:

```ts
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
```

Python:

```python
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:

```json
{
  "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_exists`, which includes the existing webhook's id.
- Leave out `events` to 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](https://sendora.se/docs/streams). Cap events belong to no stream, so they reach the webhook either way.
- `inboundContent` decides how much of a received message the `inbound` event carries, as [Inbound](https://sendora.se/docs/inbound#the-inbound-event) 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](https://sendora.se/docs/api/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`](https://sendora.se/docs/api/webhook-events#event-delivered)           | The receiving server accepted the message for the recipient.                                                                                                                             |
| [`bounced`](https://sendora.se/docs/api/webhook-events#event-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](https://sendora.se/docs/suppressions).                     |
| [`deferred`](https://sendora.se/docs/api/webhook-events#event-deferred)             | The receiving server asked Sendora to try later. Sendora keeps trying, so several may follow.                                                                                            |
| [`spam_complaint`](https://sendora.se/docs/api/webhook-events#event-spam_complaint) | The recipient reported the message as spam. The recipient is suppressed.                                                                                                                 |
| [`unsubscribed`](https://sendora.se/docs/api/webhook-events#event-unsubscribed)     | The recipient unsubscribed from a [broadcast stream](https://sendora.se/docs/broadcasts) and is suppressed on it. The event comes a minute after the click, since the recipient can undo it for that long. |
| [`cap_warning`](https://sendora.se/docs/api/webhook-events#event-cap_warning)       | The server or the account has used most of its monthly cap, at the share on [Limits](https://sendora.se/docs/limits#sending-rates-and-the-monthly-cap).                                                    |
| [`cap_reached`](https://sendora.se/docs/api/webhook-events#event-cap_reached)       | A send was refused because the monthly cap is used up.                                                                                                                                   |
| [`inbound`](https://sendora.se/docs/api/webhook-events#event-inbound)               | A message arrived on an [inbound stream](https://sendora.se/docs/inbound).                                                                                                                                 |

Only Sendora support can lift a spam complaint or an unsubscribe, as [Suppressions](https://sendora.se/docs/suppressions#lift-a-suppression) 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](https://sendora.se/docs/test-servers). 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:

1. Read `t` and every `v1` from the header. There is one `v1` for each live secret of the webhook, the newest first.
2. Compute an HMAC-SHA256 of `<t>.<raw body>` in hex. Its key is your whole secret, `whsec_` included.
3. Compare it with each `v1` in constant time. One match is enough.
4. Refuse the request if `t` is 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:

The recipe, in shell:

```bash
# 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"
```

TypeScript:

```ts
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 });
}
```

Python:

```python
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
```

With the TypeScript SDK:

`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_signature` when the signature is more than five minutes old, and `invalid_signature` when no signature in the header matches. Set `toleranceSeconds` to allow another age. With a client at hand, `sendora.webhooks.receive(request, secret)` does the same from a Fetch `Request` in one call.

With the Python SDK:

`verify_webhook` 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_signature` when the signature is more than five minutes old, and with `invalid_signature` when no signature in the header matches. Pass `tolerance`, in seconds, to allow another age.

The raw body is `request.get_data()` in Flask and `await request.body()` in FastAPI. In Django, read it with `request.read()`, since `request.body` refuses a body over `DATA_UPLOAD_MAX_MEMORY_SIZE`, 2.5 MB by default. An `inbound` event with its content can be larger, as [Limits](https://sendora.se/docs/limits#inbound-mail) shows.

Pass the body and `request.headers` to `receive`, and answer with the status it returns. In Flask, pass `dict(request.headers)`, since type checkers do not take Werkzeug's headers as a `Mapping`. Exempt the Django view from the CSRF check with `csrf_exempt`, 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:

1. Create a new secret. From then on, every delivery is signed with both.

cURL:

```bash
curl -X POST https://api.sendora.se/v1/webhooks/{webhookId}/secrets \
  -H "Authorization: Bearer $SENDORA_API_TOKEN"
```

TypeScript:

```ts
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);
```

Python:

```python
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:

```json
{
  "secretId": "5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e",
  "createdAt": "2026-09-20T12:00:00.000Z",
  "secret": "whsec_0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f"
}
```

2. Move your receiver to the new secret. Deliveries verify with either one meanwhile.
3. Delete the old secret by its id, which the webhook's `secrets` lists.

cURL:

```bash
curl -X DELETE https://api.sendora.se/v1/webhooks/{webhookId}/secrets/{secretId} \
  -H "Authorization: Bearer $SENDORA_API_TOKEN"
```

TypeScript:

```ts
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',
);
```

Python:

```python
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_limit`. Deleting the last live secret fails with `last_secret`, 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](https://app.sendora.se/servers) tab.

## Retries, dead letters and replay

Sendora retries a failed attempt with growing delays, for about a day, on the schedule on [Limits](https://sendora.se/docs/limits#webhooks). After the last retry, the delivery is `dead`. It stays listed until you replay it.

cURL:

```bash
curl 'https://api.sendora.se/v1/webhooks/{webhookId}/deliveries?status=dead&limit=50' \
  -H "Authorization: Bearer $SENDORA_API_TOKEN"
```

TypeScript:

```ts
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,
  );
}
```

Python:

```python
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:

```json
{
  "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](https://sendora.se/docs/api/post-v1-webhooks-id-deliveries-deliveryid-replay) has the call.

## Remove

cURL:

```bash
curl -X DELETE https://api.sendora.se/v1/webhooks/{webhookId} \
  -H "Authorization: Bearer $SENDORA_API_TOKEN"
```

TypeScript:

```ts
import { Sendora } from '@sendora/sdk';

const sendora = new Sendora({
  token: process.env.SENDORA_API_TOKEN,
});

await sendora.webhooks.delete(
  '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',
);
```

Python:

```python
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.
