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.
import { createHmac, timingSafeEqual } from "crypto";
// V1 (legacy, still emitted)
const expectedV1 = createHmac("sha256", secret).update(rawBody).digest("hex");
const headerV1 = req.headers["x-sendoka-signature"];
if (!timingSafeEqual(Buffer.from(expectedV1), Buffer.from(headerV1))) throw new Error("Bad sig");
// V2 (replay-resistant, recommended)
const ts = req.headers["x-sendoka-timestamp"];
const headerV2 = req.headers["x-sendoka-signature-v2"];
const expectedV2 = createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
if (!timingSafeEqual(Buffer.from(expectedV2), Buffer.from(headerV2))) 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).
- The fan-out signer uses the current
secretfor outgoing requests. - Customer verification code should accept either signature during the grace window (sign the body with both secrets, compare).
- Audit action:
webhook.rotated.