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.
Endpoint cap
The plan's endpoint cap (PLANS[*].limits.webhookEndpoints: Free 1, Pro 10,
Pay as you go 10, Enterprise unlimited) applies per scope, not per org:
- Org-level endpoints (
tenant_idnull) share one copy of the cap. The dashboard only creates these, and so doesPOST /v1/webhooksfrom a platform-root key that names notenant_id. - Each tenant gets its own copy, counting only the endpoints bound to that
tenant — created by the tenant's own key, or by a platform-root key passing
tenant_id. A tenant's endpoints never use up the org-level copy or a sibling tenant's.
So a platform registering one endpoint per tenant is bounded by its tenant cap,
not by the endpoint cap (counted org-wide, a Pro org would hit the wall at its
11th tenant). It stays a usage cap on every plan, never a lock. A create over
the cap answers 402 PLAN_RESOURCE_LIMIT (the dashboard: 402 with
{ error }), and the message names the scope that is full —
(10/10 organization-level webhook endpoints) or
(10/10 webhook endpoints for tenant ten_…). Counting lives in
src/lib/api/resource-limit.ts; every create path passes the tenant it is about
to insert under.
Delete
DELETE /api/internal/webhooks?id=whk_... (dashboard) and DELETE /v1/webhooks/{id} (API)
remove the endpoint and every webhook_deliveries row it received, in one
transaction — deliveries first, then the endpoint. The delivery history is not
retained anywhere; export it via GET /v1/webhooks/{id}/deliveries first if you
need it. Both answer 404 for an endpoint outside the caller's org / project.
(Until this was one transaction, webhook_deliveries.endpoint_id — a hard FK with
no cascade — made any endpoint that had ever received an event undeletable: the
bare endpoint delete failed with Postgres 23503 and the route answered 500.)
Events
| Event | Trigger |
|---|---|
message.sent |
Provider accepted the send (outbound only) |
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 |
suppression.created |
Inbound SMS STOP opted a recipient out — not delivered as inbound.sms, whose receivers auto-reply. See events.md |
domain.verified / domain.unverified / domain.removed / domain.warmup_started |
Domain lifecycle |
brand.* / campaign.* / phone_number.* |
SMS registration lifecycle (verified / unverified / removed-released) |
inbound.email |
Email received at a verified inbound domain (/api/webhooks/inbound-email) — subscribe to this name; it is not delivered to message.sent subscribers |
inbound.sms |
Free-form SMS reply received at a pool number |
job.started |
An audience blast sent its first message. Separate from creation because a blast ramps over a window, so accepted and started can be minutes apart |
job.completed |
An audience blast finished. Carries sent / delivered / failed / canceled / suppressed counts, so a receiver never has to aggregate the messages itself |
job.canceled |
DELETE /v1/jobs/{id}. canceled is what was actually stopped, NOT total — rows the send cron had already claimed still deliver |
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-Environment: live
X-Sendoka-Tenant-Id: ten_...
X-Sendoka-Delivery-Id: whd_...
{
"event": "message.delivered",
"data": { "message_id": "msg_...", "channel": "email", "status": "delivered" },
"timestamp": "2026-04-21T12:00:00.000Z",
"environment": "live",
"tenant_id": "ten_...",
"delivery_id": "whd_..."
}
delivery_id (body + header) is unique per delivery attempt row — use it as
your dedup key.
environment separates sok_test_* traffic from live traffic — endpoints
themselves are not split by environment, so a test send reaches the same
receivers your production sends do. tenant_id carries the platform-mode
tenant (null for org-level events); both are absent from the headers when the
producing event has no environment or no tenant.
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: 8 (
MAX_ATTEMPTSinsrc/lib/api/webhook-fanout.ts). - Backoff:
min(2^attempt minutes, 6h) + random(0..30s)— roughly 2, 4, 8, 16, 32, 64, 128 minute gaps, so the 8 attempts span a bit over 4 hours. The scale is minutes rather than seconds on purpose: the retry cron only ticks every 5 minutes, so a sub-minute schedule is swallowed whole by the cadence. - 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). - API:
GET /v1/webhooks/{id}/deliveries(read:webhooks) lists one endpoint's attempts, newest first, without payloads;?include=payloadadds each row's stored body, the same field theGET /v1/webhooks/{id}/deliveries/{deliveryId}drill-down returns.sendoka listenreads it that way, one request per page rather than one per delivery.
Auto-disable on persistent failure
Endpoints track consecutive_failures — the count of deliveries that exhausted
all 8 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.