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.
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_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).

  • The fan-out signer uses the current secret for outgoing requests.
  • Customer verification code should accept either signature during the grace window (sign the body with both secrets, compare).
  • Audit action: webhook.rotated.