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.

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_ATTEMPTS in src/lib/api/webhook-fanout.ts).
  • Backoff: min(2^attempt, 3600) * 1000 + random(0..1000) ms.
  • 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).

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.