Webhooks

Two unrelated concepts share the name "webhook":

  1. Inbound webhooks — SES/SNS and Stripe call Sendoka. See inbound SES, Stripe.
  2. 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_id null) share one copy of the cap. The dashboard only creates these, and so does POST /v1/webhooks from a platform-root key that names no tenant_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_ATTEMPTS in src/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-webhooks fires every 5 minutes, picks up pending rows with next_attempt_at <= now(), calls attemptDelivery() again.
  • Visibility: GET /api/internal/webhook-deliveries lists 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=payload adds each row's stored body, the same field the GET /v1/webhooks/{id}/deliveries/{deliveryId} drill-down returns. sendoka listen reads 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.