Skip to content

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.

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.succeeded is a UX-only signal that can be dropped. Treat policy.issued as the only authoritative post-sale event.

EventWhen it firesPayload
policy.issuedA successful, operator-attributed payment produces a commission recordPolicyIssuedPayload (see §3)

policy.issued is the only webhook event today.

No cancellation or refund webhook yet. There is no policy.cancelled, policy.refunded, or commission.voided event. You are not notified when a commission is voided or clawed back — reconcile those out-of-band. [needs-product-work]

The request body is JSON. Money is carried as integer minor units (cents) in the *Cents fields — divide by 100 for display.

FieldTypeDescription
eventstringAlways "policy.issued".
eventIdstringStable UUID. Identical across every retry of the same event — dedupe on this.
policyIdstring | nullThe carrier policy number, or null if not yet assigned.
operatorIdstringThe operator this policy is attributed to.
premiumCentsnumberPremium in integer minor units (cents).
commissionCentsnumberCommission owed in integer minor units (cents).
issuedAtnumberEpoch milliseconds when the policy was issued. Stable across retries.
sentAtnumberEpoch 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 *Cents fields. premiumCents and commissionCents are integer minor units (cents) — divide by 100 for display. Note the unit split: issuedAt/sentAt are epoch milliseconds, while the signature timestamp (§4) is unix seconds — don’t mix them.

Every delivery is signed with an HMAC over the raw request body, using the Stripe signature scheme. Verify it before trusting the payload.

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 hex HMAC-SHA256 of 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.

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 });
},
};

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

PropertyBehavior
GuaranteeAt-least-once. The same event may arrive more than once.
RetriesUp to 3 attempts total (1 initial + 2 retries) on non-2xx or timeout.
Backoff30 seconds after the first failure, then 120 seconds after the second.
Dedup keyeventId — stable across all retries of the same event.
Per-attempt timeout10 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.

Your webhook endpoint should:

  1. Respond 2xx fast. Acknowledge receipt, then do the heavy work asynchronously. Any non-2xx (or no response within 10 seconds) is treated as a failure and retried.
  2. Stay within the 10-second timeout. Don’t run fulfillment, carrier calls, or DB-heavy work inline before responding. Enqueue and return 200.
  3. Be idempotent. At-least-once delivery means you’ll occasionally see the same eventId twice. Processing it twice must not double-count a sale or a commission.
  4. Verify the signature first. Reject anything that fails verification or falls outside the ±5-minute replay window before acting on the body.
  5. 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)