Webhooks
This page documents the server-to-server webhook Expedition Insure sends to operators: the policy.issued event, its payload, how to verify the X-EI-Signature header, and the delivery guarantees you build against. It’s for the backend that reconciles sales and pays out commissions — not the browser embed.
For the client-side events your embed emits to the page (quote.ready, quote.selected, payment.succeeded, …), see events. For the HTTP request/response contracts, see API.
1. Why webhooks
Section titled “1. Why webhooks”The embed surfaces in-browser events for UX: react to quote.selected to update your cart, or payment.succeeded to show a confirmation. Those are best-effort UX signals — they can be missed if the customer closes the tab, loses connectivity, or the page never loads your handler.
The policy.issued webhook is different. It’s the authoritative, server-confirmed signal that a policy was issued and a commission was recorded. Fired server-side off the payment confirmation, it doesn’t depend on the customer’s browser staying open. Use it — not payment.succeeded — as the source of truth for reconciliation, fulfillment, and accounting.
Reconcile on the webhook, not the browser.
payment.succeededis a UX-only signal that can be dropped. Treatpolicy.issuedas the only authoritative post-sale event.
2. Events
Section titled “2. Events”| Event | When it fires | Payload |
|---|---|---|
policy.issued | A successful, operator-attributed payment produces a commission record | PolicyIssuedPayload (see §3) |
policy.issued is the only webhook event today.
No cancellation or refund webhook yet. There is no
policy.cancelled,policy.refunded, orcommission.voidedevent. You are not notified when a commission is voided or clawed back — reconcile those out-of-band. [needs-product-work]
3. Payload schema
Section titled “3. Payload schema”The request body is JSON. Money is carried as integer minor units (cents) in the *Cents fields — divide by 100 for display.
| Field | Type | Description |
|---|---|---|
event | string | Always "policy.issued". |
eventId | string | Stable UUID. Identical across every retry of the same event — dedupe on this. |
policyId | string | null | The carrier policy number, or null if not yet assigned. |
operatorId | string | The operator this policy is attributed to. |
premiumCents | number | Premium in integer minor units (cents). |
commissionCents | number | Commission owed in integer minor units (cents). |
issuedAt | number | Epoch milliseconds when the policy was issued. Stable across retries. |
sentAt | number | Epoch milliseconds this delivery attempt was sent. Differs per attempt. |
{ "event": "policy.issued", "eventId": "b1e2c3d4-5678-90ab-cdef-1234567890ab", "policyId": "POL-12345", "operatorId": "op_...", "premiumCents": 42300, "commissionCents": 4230, "issuedAt": 1733846400000, "sentAt": 1733846400000}Read the
*Centsfields.premiumCentsandcommissionCentsare integer minor units (cents) — divide by 100 for display. Note the unit split:issuedAt/sentAtare epoch milliseconds, while the signature timestamp (§4) is unix seconds — don’t mix them.
4. Signature verification
Section titled “4. Signature verification”Every delivery is signed with an HMAC over the raw request body, using the Stripe signature scheme. Verify it before trusting the payload.
The header
Section titled “The header”X-EI-Signature: t=<unix-seconds>,v1=<hex-hmac>t— the timestamp of this attempt, in unix seconds (Math.floor(Date.now() / 1000)). Not milliseconds.v1— lowercase hexHMAC-SHA256of the string`${t}.${rawBody}`keyed by your webhook secret.
The secret is symmetric: Expedition Insure signs with it, and you verify by recomputing the same HMAC and comparing. The signature is sent only when a webhook secret is configured for your account.
Sign over the raw body. Compute the HMAC against the exact bytes you received, before any JSON parsing or reserialization — re-encoding can change whitespace or key order and break the signature.
Verify and check replay (Node.js)
Section titled “Verify and check replay (Node.js)”import crypto from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60; // ±5 min
function verifyWebhook(rawBody, signatureHeader, secret) { // Parse "t=...,v1=..." const parts = Object.fromEntries( signatureHeader.split(",").map((kv) => kv.split("=")), ); const t = Number(parts.t); const v1 = parts.v1; if (!Number.isFinite(t) || !v1) return false;
// Reject stale or future-dated timestamps (replay / clock skew). const now = Math.floor(Date.now() / 1000); if (Math.abs(now - t) > TOLERANCE_SECONDS) return false;
// Recompute the expected signature over `${t}.${rawBody}`. const expected = crypto .createHmac("sha256", secret) .update(`${t}.${rawBody}`) .digest("hex");
// Constant-time compare. const a = Buffer.from(expected, "hex"); const b = Buffer.from(v1, "hex"); return a.length === b.length && crypto.timingSafeEqual(a, b);}Capture rawBody as the unparsed string. In Express, use express.raw({ type: "application/json" }) for the webhook route so req.body is a Buffer, then req.body.toString("utf8").
Verify on the edge / Cloudflare Workers (Web Crypto)
Section titled “Verify on the edge / Cloudflare Workers (Web Crypto)”const TOLERANCE_SECONDS = 5 * 60; // ±5 min
async function verifyWebhook(rawBody, signatureHeader, secret) { const parts = Object.fromEntries( signatureHeader.split(",").map((kv) => kv.split("=")), ); const t = Number(parts.t); const v1 = parts.v1; if (!Number.isFinite(t) || !v1) return false;
const now = Math.floor(Date.now() / 1000); if (Math.abs(now - t) > TOLERANCE_SECONDS) return false;
const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const sig = await crypto.subtle.sign( "HMAC", key, new TextEncoder().encode(`${t}.${rawBody}`), ); const expected = [...new Uint8Array(sig)] .map((b) => b.toString(16).padStart(2, "0")) .join("");
// Length-then-content compare (both hex). if (expected.length !== v1.length) return false; let mismatch = 0; for (let i = 0; i < expected.length; i++) { mismatch |= expected.charCodeAt(i) ^ v1.charCodeAt(i); } return mismatch === 0;}
export default { async fetch(request) { const rawBody = await request.text(); const sigHeader = request.headers.get("X-EI-Signature") ?? ""; const ok = await verifyWebhook(rawBody, sigHeader, YOUR_WEBHOOK_SECRET); if (!ok) return new Response("invalid signature", { status: 400 });
const event = JSON.parse(rawBody); // ... handle event ... return new Response("ok", { status: 200 }); },};Replay window and clock skew
Section titled “Replay window and clock skew”Reject any request whose t falls outside a ±5 minute tolerance of your server’s clock. This bounds replay attacks and absorbs normal clock skew. Both verify snippets above enforce it. Tolerance is enforced on your side — keep your server clock in sync (NTP).
5. Delivery semantics
Section titled “5. Delivery semantics”| Property | Behavior |
|---|---|
| Guarantee | At-least-once. The same event may arrive more than once. |
| Retries | Up to 3 attempts total (1 initial + 2 retries) on non-2xx or timeout. |
| Backoff | 30 seconds after the first failure, then 120 seconds after the second. |
| Dedup key | eventId — stable across all retries of the same event. |
| Per-attempt timeout | 10 seconds. A slower response is treated as a failure and retried. |
Because delivery is at-least-once, dedupe on eventId. Persist each eventId you’ve processed and ignore repeats. Each retry carries the same eventId but a fresh sentAt and a fresh signature, so don’t key idempotency on sentAt or the signature.
A non-2xx response or a request that exceeds the 10-second timeout triggers a retry. After the third attempt fails, delivery stops — there’s no further automatic redelivery, so reconcile missed events against your own records.
6. Endpoint requirements
Section titled “6. Endpoint requirements”Your webhook endpoint should:
- Respond
2xxfast. Acknowledge receipt, then do the heavy work asynchronously. Any non-2xx (or no response within 10 seconds) is treated as a failure and retried. - Stay within the 10-second timeout. Don’t run fulfillment, carrier calls, or DB-heavy work inline before responding. Enqueue and return
200. - Be idempotent. At-least-once delivery means you’ll occasionally see the same
eventIdtwice. Processing it twice must not double-count a sale or a commission. - Verify the signature first. Reject anything that fails verification or falls outside the ±5-minute replay window before acting on the body.
- Read the raw body before parsing. Signature verification needs the exact received bytes.
request → verify X-EI-Signature (HMAC + ±5 min window) → reject if invalid → 400 → seen this eventId? → 200 (ignore duplicate) → record eventId, enqueue work → 200 (fast ack)