Webhooks
OpenInsure uses webhooks in two directions: inbound webhooks receive events from third-party services (Stripe, Persona, Resend, Documenso), and outbound webhooks let tenants subscribe to platform events and receive signed HTTP callbacks at their own endpoints.
Security Model
Every webhook endpoint follows the same hardened pipeline, implemented in apps/api/src/lib/webhook-security.ts:
- Signature verification -- Each provider uses its own signing scheme (Stripe SDK, Svix for Resend, HMAC-SHA256 for Persona and outbound hooks). Signatures are verified with timing-safe comparison before any payload processing.
- Timestamp validation -- Payloads older than 5 minutes are rejected to prevent replay attacks.
- Idempotency deduplication -- Every processed event ID is stored in Cloudflare KV with a 24-hour TTL. Duplicate deliveries return
200without re-processing. - Error isolation -- Processing failures return
500so the upstream provider retries. The event is only marked as processed after successful handling.
Request arrives
-> Verify signature (provider-specific)
-> Check KV for duplicate event ID
-> Validate timestamp freshness (< 5 min)
-> Process event
-> Mark event ID in KV
-> Return 200
Stripe Webhook
Route: POST /webhooks/stripe
Signature: Verified using the official Stripe SDK via verifyWebhookSignature(). Requires STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET environment variables.
Handled Event Types
| Stripe Event | Action |
|---|---|
payment_intent.succeeded | Record payment, update invoice status, compute commission split, sync to TigerBeetle ledger, generate receipt HTML in R2 |
payment_intent.payment_failed | Escalate financial alert via EVENTS_QUEUE |
charge.refunded | Record negative payment, revert invoice status, flag large refunds (> $1,000) for manual review |
Unrecognized event types are acknowledged with 200 to prevent infinite retries.
Payment Processing Flow
When payment_intent.succeeded fires:
- The payment amount (Stripe sends cents) is converted to dollars and recorded in the
paymentstable with the Stripe payment intent ID as thereferencefor idempotency. - The invoice
paidAmountis updated. If the invoice is fully paid, its status transitions topaid. - A receipt HTML is rendered via
buildReceiptHTML()from@openinsure/billingand stored in R2 atdocs/{orgId}/receipts/RCPT-{invoiceNumber}-{timestamp}.html. - If the invoice is fully paid and the policy has a producer, the commission split is calculated:
- Commission rates are loaded from the database (
commission_schedulestable), with LOB-aware tier selection viafindCommissionTier(). - The gross premium is split into agent commission, MGA commission, and carrier net using the
Moneytype from@openinsure/rating. - A
commissionsrow is inserted and the split is posted to the general ledger viapostBusinessEventToGL(). - The fiduciary split is recorded to TigerBeetle via
recordSplitToLedger().
- Commission rates are loaded from the database (
- An
invoice.paidevent is published toEVENTS_QUEUE.
Financial Failure Escalation
All financial operation failures (ledger sync, commission calculation, refund processing) are escalated through escalateFinancialFailure():
- The error is logged with full context.
- A
financial.operation_failedevent is queued withseverity: 'critical'andrequiresManualReview: true. - Ledger sync failures are flagged as P1 incidents.
Persona Webhook
Route: POST /webhooks/persona
Signature: Verified via PersonaClient.validateWebhook() from @openinsure/auth using the Persona-Signature header and PERSONA_WEBHOOK_SECRET.
Replay protection: The SHA-256 hash of the signature header is stored in KV with a 10-minute TTL. Duplicate deliveries return 409.
Handled Events
| Persona Event | Action |
|---|---|
inquiry.completed | Set producer status to active in the producers table |
| All other events | Acknowledged with 202 and ignored |
The producer is identified by the reference-id attribute on the included inquiry object. If the reference ID is missing, the event is acknowledged but no action is taken.
Resend Webhook
Route: POST /webhooks/resend
Signature: Verified using the Svix library. Requires three headers: svix-id, svix-timestamp, and svix-signature. The RESEND_WEBHOOK_SECRET environment variable provides the verification key.
Processing
Rather than handling email events synchronously, the Resend webhook enqueues every verified event to EMAIL_EVENTS_QUEUE for async processing:
{
"type": "email.delivered",
"createdAt": "2026-03-24T12:00:00Z",
"data": { "to": "insured@example.com", "subject": "Your policy documents" },
"svixId": "msg_abc123"
}
The svixId is used as the deduplication key. If an event with the same svixId has already been processed, the webhook returns 200 with { "deduplicated": true }.
Resend Event Types
All Resend event types are forwarded to the queue, including:
email.sentemail.deliveredemail.openedemail.clickedemail.bouncedemail.complained
E-Signature Webhook (Documenso / DocuSign)
Route: POST /v1/esign/webhook
This endpoint is mounted outside the JWT middleware stack -- it relies entirely on signature verification for authentication.
Signature: Verified via verifyWebhookSignature() from @openinsure/documents. The signature header is read from whichever is present: x-webhook-signature, x-documenso-signature, or x-docusign-signature-1.
Provider Normalization
The webhook handler normalizes status values across multiple e-signature providers:
| Provider Event | Normalized Status |
|---|---|
DOCUMENT_COMPLETED / DOCUMENT_SIGNED | completed |
DOCUMENT_REJECTED / DOCUMENT_DECLINED | declined |
DOCUMENT_CANCELLED / DOCUMENT_VOIDED | voided |
DOCUMENT_OPENED | viewed |
DOCUMENT_SENT | sent |
DOCUMENT_EXPIRED | expired |
The external document ID (documentId for Documenso/BoldSign, envelopeId for DocuSign) is used to look up and update the corresponding signing_envelopes row. When the status transitions to completed, the completedAt timestamp is also set.
Outbound Webhooks (Tenant-Registered)
Routes: GET/POST/DELETE /v1/webhooks, POST /v1/webhooks/:id/test
Tenants can register their own webhook endpoints to receive platform events. Outbound webhooks are scoped to the organization and require the org_admin role.
Registration
POST /v1/webhooks
Authorization: Bearer <token>
Content-Type: application/json
{
"url": "https://erp.example.com/hooks/openinsure",
"events": ["invoice.paid", "policy.bound", "claim.opened"],
"description": "ERP sync"
}
The response includes a secret field -- a 64-character hex string generated from 32 random bytes. This secret is returned only at creation time and is used by the tenant to verify delivery signatures.
Delivery Signing
Every outbound delivery includes three headers:
| Header | Description |
|---|---|
X-OpenInsure-Signature | HMAC-SHA256 hex digest of the body, prefixed with sha256= |
X-OpenInsure-Event | The event type (e.g., invoice.paid) |
X-OpenInsure-Delivery | Unique delivery UUID for idempotency |
Verification Example
import { createHmac } from 'node:crypto';
function verify(body: string, signature: string, secret: string): boolean {
const expected = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
Test Delivery
POST /v1/webhooks/:id/test
Fires a webhook.test event to the registered URL. The delivery has a 10-second timeout. The response includes delivered (boolean) and the HTTP status code from the target.
Deactivation
Webhooks are soft-deleted (marked active: false). Inactive webhooks cannot receive test deliveries (returns 422).
DELETE /v1/webhooks/:id
Environment Variables
| Variable | Provider | Purpose |
|---|---|---|
STRIPE_SECRET_KEY | Stripe | SDK client initialization |
STRIPE_WEBHOOK_SECRET | Stripe | Signature verification |
PERSONA_API_KEY | Persona | Client initialization |
PERSONA_WEBHOOK_SECRET | Persona | Signature verification |
RESEND_WEBHOOK_SECRET | Resend | Svix signature verification |
ESIGN_PROVIDER | Documenso / DocuSign | Provider selection |
ESIGN_API_KEY | Documenso / DocuSign | Provider API key |
ESIGN_WEBHOOK_SECRET | Documenso / DocuSign | Signature verification |
KV_CACHE | Cloudflare | Idempotency and replay protection |
EVENTS_QUEUE | Cloudflare | Async event processing |
EMAIL_EVENTS_QUEUE | Cloudflare | Resend event queue |
Retry Behavior
OpenInsure does not implement its own retry logic for inbound webhooks. Instead, it relies on the upstream provider's retry policy:
- Stripe: Retries up to 3 days with exponential backoff.
- Resend (Svix): Retries with exponential backoff for up to 72 hours.
- Persona: Retries up to 5 times with exponential backoff.
- Documenso: Single retry after 30 seconds, then manual re-trigger.
For outbound webhooks, the platform currently delivers once with a 10-second timeout. Failed deliveries are not automatically retried -- tenant systems should be designed to tolerate missed events and reconcile via polling.