# Webhooks

> Have Sendai call your endpoint when a message is delivered or fails — signed with HMAC-SHA256 so you can prove every delivery came from us.

Polling `GET /api/v1/sms/{id}` works, but for anything beyond a handful of messages you want
Sendai to tell *you*. Register a webhook endpoint and Sendai POSTs signed JSON to your URL
as events happen — each request carries an HMAC-SHA256 signature so you can verify it came
from Sendai and not from anyone who knows your URL.

## The events

An endpoint subscribes to one or more of four events:

- **`message.delivered`** (`event`)
  Fires when the carrier confirms a message reached the handset — the same fact as
  `delivered_at` becoming non-null on [`GET /api/v1/sms/{id}`](/api/sms#retrieve-an-sms). One event per
  successful delivery receipt, so a bulk send to 5,000 recipients produces up to 5,000 of
  these.

- **`message.failed`** (`event`)
  Fires when a message fails — bounces and carrier failures. The push-side counterpart of a
  message ending in `status: failed`.

- **`campaign.started`** (`event`)
  Fires when a campaign composed in the dashboard begins sending.

- **`campaign.completed`** (`event`)
  Fires when a campaign finishes sending — the bookend to `campaign.started`.

For per-recipient delivery tracking, subscribe to the two `message.*` events. The
`campaign.*` pair covers campaigns composed in the dashboard, and is useful when you care
about the campaign as a unit rather than each recipient.

## What a delivery looks like

Every delivery is an HTTPS `POST` of a JSON body to your endpoint, with one header that
matters:

- **`X-Sendai-Signature`** (`header`)
  An **HMAC-SHA256** of the request body, keyed with your endpoint's signing secret (the
  `whsec_…` value from registration). Verify it before trusting the payload — an unsigned or
  mis-signed request is not from Sendai.

The exact JSON body shape is not part of the documented contract yet, so don't build against
guessed field names — capture a real body with a [test delivery](#test-deliveries) and treat
the event name and the message id it references as the parts to key your processing on.

## Register an endpoint

Register endpoints in the dashboard under **Settings**: give it a URL and pick the events it
should receive. The URL must be **HTTPS**, and the backend rejects unresolvable or private
hosts — a webhook pointed at `localhost` will not register; use a tunnel while developing.

The create step responds with the endpoint's **signing secret** — a `whsec_` value.

> **The signing secret is shown once, at creation.** Sendai cannot show it again afterwards —
> store it in your secret manager immediately, alongside the API keys. If you lose it, delete
> the endpoint and register it again to get a new secret.

## Verify every delivery

Two rules make verification work reliably:

- **Verify the raw bytes.** Compute the HMAC over the body exactly as received, *before* any
  JSON parsing — a re-serialised body will not match.
- **Compare in constant time.** Use your platform's timing-safe comparison, not `===` or
  `==`.

```js [Node.js]
import { createHmac, timingSafeEqual } from 'node:crypto'
import express from 'express'

const app = express()

// express.raw keeps the body as the exact bytes Sendai signed.
app.post('/webhooks/sendai', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = createHmac('sha256', process.env.SENDAI_WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex')
  const received = req.get('X-Sendai-Signature') ?? ''

  const a = Buffer.from(expected)
  const b = Buffer.from(received)
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    return res.status(401).end()
  }

  const event = JSON.parse(req.body)
  // Acknowledge fast; do real work after responding.
  res.status(200).end()
})
```

```python [Python]
import hashlib
import hmac
import os

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["SENDAI_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/sendai")
def sendai_webhook():
    # request.get_data() is the raw bytes Sendai signed — before any JSON parsing.
    raw = request.get_data()
    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    received = request.headers.get("X-Sendai-Signature", "")

    # hmac.compare_digest is the constant-time comparison.
    if not hmac.compare_digest(expected, received):
        abort(401)

    event = request.get_json()
    # Acknowledge fast; do real work after responding.
    return "", 200
```

Both examples assume the signature is **hex-encoded**. Use the dashboard's
[test delivery](#test-deliveries) to confirm your verification passes end-to-end before you
rely on it in production — that check also settles the encoding question against the real
thing rather than this page.

## Test deliveries

From the endpoint's row in the dashboard you can send a **test delivery**: Sendai fires a
real, signed request at your URL and reports back what happened — whether it succeeded, the
HTTP status code your endpoint returned, and the latency. A refused or failing endpoint is
reported as a failed test, not hidden. Use it to prove connectivity and your signature check
before real traffic depends on them — and to capture a real payload from your endpoint's own
logs.

The dashboard also shows each endpoint's **last delivery** — when it was, whether it
succeeded, and the status code — so a misbehaving endpoint is visible at a glance.

## Disable, update, delete

Endpoints are managed from the same **Settings** list:

- **Disable** an endpoint to stop deliveries without losing its URL, events, or secret —
  re-enable it and deliveries resume. Useful during maintenance windows.
- **Update** its URL or event subscriptions in place; the signing secret is unchanged.
- **Delete** it to remove it entirely. The secret dies with it — registering the same URL
  again produces a new endpoint with a new secret.

## Handling deliveries well

- **Respond `2xx` quickly, process async.** Acknowledge first — enqueue the event and do the
  real work after responding. Slow handlers show up as failing deliveries.
- **Make handlers idempotent.** Key your processing on the message id plus the event name,
  so handling the same event twice is harmless — deduplication on your side costs one lookup
  and removes a whole class of bugs.
- **Keep a reconciliation fallback.** A retry schedule is not part of the documented
  contract, so treat webhooks as the fast path and poll
  [`GET /api/v1/sms/{id}`](/api/sms#retrieve-an-sms) on a slow interval for anything you cannot afford
  to miss — `delivered_at` there is the same fact `message.delivered` pushes.

## Per-send callbacks on bulk

Separately from account-level endpoints, [`POST /api/v1/sms/bulk`](/api/sms#send-bulk-sms)
accepts an optional `webhook_url` applied to every message in that one send — useful when a
particular campaign should report to a different URL.

## Next steps

- The delivery signal explained → [Retrieve an SMS](/api/sms#retrieve-an-sms)
- Send to many recipients → [Bulk SMS](/guides/bulk-sms)
- Let an AI agent do the sending → [AI agents](/guides/mcp)
