Webhooks
Two unrelated concepts share the name "webhook":
- Inbound webhooks — SES/SNS and Stripe call Sendoka. See inbound SES, Stripe.
- Outbound webhooks — Sendoka calls the customer's URL on message lifecycle events. This doc covers those.
Customer-configured outbound webhooks
Dashboard: /overview/webhooks. Internal API: /api/internal/webhooks.
Delivery: src/lib/api/webhook-fanout.ts.
Create
POST /api/internal/webhooks
{
"url": "https://api.customer.com/hooks/sendoka",
"events": ["*"]
}
Response (201):
{ "id": "whk_...", "secret": "whsec_<nanoid32>" }
The secret is the HMAC-SHA256 signing key. Store it — it's not shown in list responses.
Events
| Event | Trigger |
|---|---|
message.sent |
Provider accepted the send; also fires for inbound email at a verified domain (/api/webhooks/inbound-email) |
message.delivered |
SES Delivery notification or SNS SMS success |
message.bounced |
SES Bounce |
message.complained |
SES Complaint (recipient marked spam) |
message.failed |
SES Reject or SNS SMS failure |
message.opened |
Tracking pixel loaded (deduped per message+IP per hour) |
message.clicked |
Tracked link followed |
message.unsubscribed |
One-click POST or unsubscribe landing page |
domain.verified / domain.unverified / domain.removed / domain.warmup_started |
Domain lifecycle |
brand.* / campaign.* / phone_number.* |
SMS registration lifecycle (verified / unverified / removed-released) |
inbound.sms |
Free-form SMS reply received at a pool number |
dlt.template_approved / dlt.template_rejected |
India DLT template status |
* |
All of the above |
See docs/api/events.md for payload schemas.
Delivery
For each enabled endpoint whose events includes the firing event (or *):
POST <url>
Content-Type: application/json
X-Sendoka-Signature: <hex-hmac-sha256-of-body>
X-Sendoka-Timestamp: <unix-seconds>
X-Sendoka-Signature-V2: <hex-hmac-sha256-of `${timestamp}.${body}`>
X-Sendoka-Event: message.delivered
X-Sendoka-Delivery-Id: whd_...
{
"event": "message.delivered",
"data": { "message_id": "msg_...", "channel": "email", "status": "delivered" },
"timestamp": "2026-04-21T12:00:00.000Z",
"delivery_id": "whd_..."
}
delivery_id (body + header) is unique per delivery attempt row — use it as
your dedup key.
Timeout: 10s per call (AbortSignal.timeout(10_000)).
Signature verification (customer side)
Two signatures ship on every delivery:
X-Sendoka-Signature— HMAC-SHA256 of the raw body. Stable contract; receivers built against this header keep working.X-Sendoka-Signature-V2+X-Sendoka-Timestamp— HMAC-SHA256 of${timestamp}.${body}. Lets you reject replays. Prefer this for new integrations.
Each header may carry a comma-separated list of signatures — one per active
signing secret. During a secret rotation grace window both
the new and the old secret sign the delivery, so always split on , and accept
the delivery if any value matches. Outside rotation it's a single value.
import { createHmac, timingSafeEqual } from "crypto";
// Accept if any comma-separated signature matches our secret. Rotation sends
// one signature per active secret; outside rotation there's just one.
function matchesAny(header: string, expected: string): boolean {
const a = Buffer.from(expected);
return header.split(",").some((sig) => {
const b = Buffer.from(sig.trim());
return a.length === b.length && timingSafeEqual(a, b);
});
}
// V2 (replay-resistant, recommended)
const ts = req.headers["x-sendoka-timestamp"];
const expectedV2 = createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
if (!matchesAny(req.headers["x-sendoka-signature-v2"], expectedV2)) throw new Error("Bad sig");
// V1 (legacy, still emitted)
const expectedV1 = createHmac("sha256", secret).update(rawBody).digest("hex");
if (!matchesAny(req.headers["x-sendoka-signature"], expectedV1)) throw new Error("Bad sig");
// Reject deliveries older than 5 minutes — protects against replays even if the
// signature is still valid (e.g. an attacker captures a delivery and re-sends).
const ageSec = Math.floor(Date.now() / 1000) - Number(ts);
if (Math.abs(ageSec) > 300) throw new Error("Stale delivery");
Delivery tracking & retries
Fan-out runs after the HTTP response is sent to the source webhook handler via next/server's after(). Each enabled endpoint gets a webhook_deliveries row before the attempt. The attempt updates attempts, last_status_code, last_error, last_response_body (first 1 KB of the receiver's response), and either marks the row delivered or schedules next_attempt_at with exponential backoff + jitter.
- Max attempts: 5 (
MAX_ATTEMPTSinsrc/lib/api/webhook-fanout.ts). - Backoff:
min(2^attempt, 3600) * 1000 + random(0..1000)ms. - Retry cron:
/api/cron/retry-webhooksfires every 5 minutes, picks uppendingrows withnext_attempt_at <= now(), callsattemptDelivery()again. - Visibility:
GET /api/internal/webhook-deliverieslists attempts for the active org (paginate with?limit+ optional?endpoint_id).
Auto-disable on persistent failure
Endpoints track consecutive_failures — the count of deliveries that exhausted
all 5 attempts since the last successful delivery. Any 2xx resets the streak.
At 10 consecutive exhausted deliveries the endpoint is flipped to
enabled = false with disabled_reason set, and a webhook.auto_disabled
audit entry is written. A receiver that's been hard-down for many hours stops
consuming retry budget instead of failing forever.
Re-enable via PATCH /api/v1/webhooks/:id { "enabled": true } — this clears
the streak and the reason. Recover missed events with
bulk replay.
Secret rotation
POST /api/internal/webhooks/rotate { id, grace_hours? } generates a new whsec_* and stores the old one as previous_secret with previous_secret_expires_at = now() + grace_hours (default 24h, max 168h).
- During the grace window the signer dual-signs: every delivery (and retry) is signed with both the new and the old secret, and each
X-Sendoka-Signature*header carries a comma-separated list — one signature per secret. - So a receiver verifying with either the old or the new secret accepts deliveries throughout the window: rotate the live secret, then roll out your config change any time before the window closes — zero dropped deliveries. Your verifier must split on
,and accept any match (see verification). - When the window expires, only the current secret signs.
- Audit action:
webhook.rotated.