Guides
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.deliveredeventFires when the carrier confirms a message reached the handset — the same fact asdelivered_atbecoming non-null onGET /api/v1/sms/{id}. One event per successful delivery receipt, so a bulk send to 5,000 recipients produces up to 5,000 of these.message.failedeventFires when a message fails — bounces and carrier failures. The push-side counterpart of a message ending instatus: failed.campaign.startedeventFires when a campaign composed in the dashboard begins sending.campaign.completedeventFires when a campaign finishes sending — the bookend tocampaign.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-SignatureheaderAn HMAC-SHA256 of the request body, keyed with your endpoint's signing secret (thewhsec_…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 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==.
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()
})
Both examples assume the signature is hex-encoded. Use the dashboard's test delivery 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
2xxquickly, 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}on a slow interval for anything you cannot afford to miss —delivered_atthere is the same factmessage.deliveredpushes.
Per-send callbacks on bulk
Separately from account-level endpoints, POST /api/v1/sms/bulk
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
- Send to many recipients → Bulk SMS
- Let an AI agent do the sending → AI agents