Webhooks
There are two independent webhook directions:
- Outbound — the platform delivers tenant events to a URL the tenant registered.
- Inbound — third-party providers (Resend, Twilio) call the platform's public endpoints.
Outbound webhook delivery
Outbound delivery is implemented in WebhookService (api/src/domains/webhook.ts). Registration and inspection are admin-gated under /v1 (api/src/routes/admin.ts):
| Method | Path | Permission |
|---|---|---|
GET | /v1/webhooks | tenant member |
POST | /v1/webhooks | integrations.manage |
POST | /v1/webhooks/:id/rotate-secret | integrations.manage |
DELETE | /v1/webhooks/:id | integrations.manage |
POST | /v1/webhooks/test | integrations.manage |
GET | /v1/webhook-deliveries | integrations.manage |
POST | /v1/webhook-deliveries/:id/replay | integrations.manage |
Subscription, scope, and content toggles
A webhook subscribes to events and is optionally scoped to specific surveys. dispatch(ctx, event, payload, surveyId) enqueues one delivery row per webhook whose subscription matches via subscribed(event, surveyId):
- Events — a webhook that lists no events receives everything; otherwise only its listed events.
- Survey scope (
survey_ids) — a webhook with a non-empty scope fires only for responses to one of those surveys; an empty scope = all surveys. The filter is applied insubscribed, i.e. at enqueue time, so an out-of-scope survey never even queues a delivery row.
Each registration also carries content toggles — include_text, include_custom_data, include_answers (all default on). The dispatched response.completed payload is built with three content sections (answers, text = the verbatim free-text answers, customData); a toggle that is off redacts the matching section. Redaction happens at delivery-build time, so the omission is reflected in the exact bytes that are signed and sent — a tenant can keep verbatims / PII off a third-party endpoint without weakening the signature.
async dispatch(ctx, event, payload, surveyId) {
const ids = await this.db.forTenant(ctx).transaction(async (r) => {
const hooks = await r.webhooks.subscribed(event, surveyId); // event + survey scope
const out: string[] = [];
for (const h of hooks) out.push(await r.webhooks.enqueue(h.id, event, payload));
return out;
});
if (ids.length > 0) {
const due = await this.db.forTenant(ctx).transaction((r) => r.webhooks.pendingByIds(ids));
await this.attempt(ctx, due); // each row redacted + signed at build time
}
return { enqueued: ids.length };
}
dispatch attempts only the rows it just enqueued — not the whole tenant backlog — so an unrelated, down endpoint isn't re-hammered on every event.
HMAC signing (verify the request came from us)
Every delivery is signed. On create we mint a per-webhook signing secret (whsec_ + 64 hex chars of CSPRNG entropy) and return it once in the POST /v1/webhooks response (like an API-key plaintext). It is never echoed again: GET /v1/webhooks returns only a masked form (whsec_ab12••••••••). POST /v1/webhooks/:id/rotate-secret regenerates it and again returns the new plaintext once.
For each attempt the runner takes the exact request body string and computes, with Web Crypto (crypto.subtle, so it runs in the Worker):
timestamp = current unix seconds
signedStr = `${timestamp}.${body}` // timestamp bound IN
signature = "v1=" + hex( HMAC_SHA256(secret, signedStr) )
and sends two headers:
| Header | Value |
|---|---|
X-VOC-Timestamp | the unix-seconds timestamp |
X-VOC-Signature | v1=<hex> |
Because the timestamp is folded into the signed string (not merely sent beside it), a captured request cannot be replayed with a fresh time — changing the time changes the signed input and invalidates the signature. A webhook with no secret (a legacy registration whose secret hasn't been rotated in) is delivered without the signature headers.
Receiver verification recipe
// secret = your whsec_… signing secret; req = the inbound HTTP request
const ts = req.headers['x-voc-timestamp'];
const sig = req.headers['x-voc-signature']; // "v1=<hex>"
const body = await readRawBodyExactly(req); // the bytes as received — do NOT re-serialize
// 1. Reject stale requests (defeats replay): e.g. within 5 minutes of now.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) reject();
// 2. Recompute and constant-time compare.
const expected = 'v1=' + hmacSha256Hex(secret, `${ts}.${body}`);
if (!timingSafeEqual(expected, sig)) reject();
The core helpers signWebhookBody / verifyWebhookSignature (packages/core/src/webhook-signing.ts) implement exactly this scheme and are exercised by api/test/webhook-signing.test.ts (sign-then-verify round-trip, wrong-secret rejection, replay-with-new-timestamp rejection, and that the signature covers the redacted body).
Secret rotation
Rotating replaces the secret immediately. Deliveries already queued before the rotation (and any in-flight retries) may still be signed with the old secret, since the runner reads the webhook's current secret at attempt time and a row enqueued earlier could be attempted either side of the rotation. During the rotation window a receiver should accept either the old or the new secret, then drop the old one once you've confirmed no further old-secret deliveries arrive.
Retry and dead-letter
The injectable WebhookPoster defaults to fetch. On a non-OK status (or a thrown error) the delivery is marked failed; after MAX_ATTEMPTS (5) it is dead-lettered (status = failed):
runPending(ctx, limit)is the broad retry sweep over the tenant's still-pending backlog — driven by the retry runner (cron,runPendingWebhooksinapi/src/scheduled.ts, which redacts + signs with the same helper so a retried delivery carries an identical signed body).listDeliveries(ctx)surfaces the delivery ledger (delivered, pending, and dead-lettered rows) for inspection.replay(ctx, deliveryId)re-queues a dead-lettered delivery and runs the pending set again — the manual replay path behindPOST /v1/webhook-deliveries/:id/replay.
POST /v1/webhooks/test posts to a URL synchronously and returns the HTTP status, for verifying an endpoint at setup time (it is unsigned — the signed path is the real delivery).
Survey-send events
survey.sent is emitted for accepted email, SMS and outbound-IVR invitations. Provider acceptance creates one PII-free tenant.survey_delivery row; its insert trigger stages the subscribed notification/webhook fan-out in the same transaction. The payload contains only the public survey id, channel and acceptance time — never the recipient. Queue delivery only wakes the durable notification worker, while the 15-minute recovery sweep repairs lost wakes and accepted provider calls whose local checkpoint was interrupted. A unique source binding makes duplicate provider callbacks, queue redelivery and recovery replays idempotent.
Inbound provider webhooks
These are /public/* endpoints (no tenant session). Signatures are verified inside the adapter, and both fail closed in production.
Resend (email events) — Svix-signed
POST /public/email/events (api/src/routes/badges.ts) parses the event through ports.email.parseWebhook(req). The Resend adapter (packages/adapters/src/resend.ts) verifies the Svix signature when a webhookSecret (whsec_…) is configured:
async parseWebhook(req) {
const raw = await req.text();
if (cfg.webhookSecret) await verifySvixSignature(req.headers, raw, cfg.webhookSecret);
else if (cfg.requireSignedWebhooks)
throw new Error('email webhook secret not configured; rejecting unsigned event');
// … map email.delivered / email.bounced / email.complained
}
verifySvixSignature recomputes HMAC-SHA256 over id.timestamp.body using the decoded whsec_ secret and checks membership against the space-separated svix-signature header values. The fail-closed switch is requireSignedWebhooks (set in production): with no secret configured, an unsigned event is rejected rather than trusted — so a forged bounce can't poison the suppression list. The route then dispatches per type: bounced and complained each stamp the matching send and suppress the recipient (a complaint is a hard opt-out), while delivered stamps delivered_at only. The opaque voc_delivery tag lets a signed callback be durably retained even when it arrives before the send path has stored the provider id. Parsed and persisted events return 200; an invalid payload returns 400, and a transient persistence failure returns 503 with Retry-After so the provider retries rather than losing the receipt.
Twilio (SMS / voice) — signature-verified
POST /public/twilio/sms and POST /public/twilio/voice/:surveyId (api/src/routes/twilio.ts) verify the X-Twilio-Signature header. The signatureOk helper fails closed in production:
async function signatureOk(c, form) {
const token = (c.env as Env).TWILIO_AUTH_TOKEN;
if (!token) return (c.env as Env).ENVIRONMENT !== 'production'; // unconfigured: allow in dev, reject in prod
const params = {/* string POST params */};
return verifyTwilioSignature(token, c.req.url, params, c.req.header('x-twilio-signature'));
}
verifyTwilioSignature (api/src/lib/twilio-signature.ts) recomputes Twilio's HMAC-SHA1 over the request URL followed by each POST param key+value sorted by key, base64-encodes it, and compares constant-time. A mismatch returns 403. These webhooks drive the turn-by-turn conversation engine — see SMS & IVR.