Hoppa till innehållet

After sending

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

POST /v1/webhooks
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_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. 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 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.

EventSent when
deliveredThe receiving server accepted the message for the recipient.
bouncedThe receiving server refused the message, or Sendora gave up retrying. After a hard bounce, the recipient is on the stream’s suppression list.
deferredThe receiving server asked Sendora to try later. Sendora keeps trying, so several may follow.
spam_complaintThe recipient reported the message as spam. The recipient is suppressed.
unsubscribedThe 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_warningThe server or the account has used most of its monthly cap, at the share on Limits.
cap_reachedA send was refused because the monthly cap is used up.
inboundA 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:

  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:

# 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_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.

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 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.
POST /v1/webhooks/{webhookId}/secrets
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"
}
  1. Move your receiver to the new secret. Deliveries verify with either one meanwhile.
  2. Delete the old secret by its id, which the webhook’s secrets lists.
DELETE /v1/webhooks/{webhookId}/secrets/{secretId}
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_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 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.

GET /v1/webhooks/{webhookId}/deliveries?status=dead&limit=50
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

DELETE /v1/webhooks/{webhookId}
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.