Skip to main content

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:

  1. 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.
  2. Timestamp validation -- Payloads older than 5 minutes are rejected to prevent replay attacks.
  3. Idempotency deduplication -- Every processed event ID is stored in Cloudflare KV with a 24-hour TTL. Duplicate deliveries return 200 without re-processing.
  4. Error isolation -- Processing failures return 500 so 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 EventAction
payment_intent.succeededRecord payment, update invoice status, compute commission split, sync to TigerBeetle ledger, generate receipt HTML in R2
payment_intent.payment_failedEscalate financial alert via EVENTS_QUEUE
charge.refundedRecord 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:

  1. The payment amount (Stripe sends cents) is converted to dollars and recorded in the payments table with the Stripe payment intent ID as the reference for idempotency.
  2. The invoice paidAmount is updated. If the invoice is fully paid, its status transitions to paid.
  3. A receipt HTML is rendered via buildReceiptHTML() from @openinsure/billing and stored in R2 at docs/{orgId}/receipts/RCPT-{invoiceNumber}-{timestamp}.html.
  4. 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_schedules table), with LOB-aware tier selection via findCommissionTier().
    • The gross premium is split into agent commission, MGA commission, and carrier net using the Money type from @openinsure/rating.
    • A commissions row is inserted and the split is posted to the general ledger via postBusinessEventToGL().
    • The fiduciary split is recorded to TigerBeetle via recordSplitToLedger().
  5. An invoice.paid event is published to EVENTS_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_failed event is queued with severity: 'critical' and requiresManualReview: 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 EventAction
inquiry.completedSet producer status to active in the producers table
All other eventsAcknowledged 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.sent
  • email.delivered
  • email.opened
  • email.clicked
  • email.bounced
  • email.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 EventNormalized Status
DOCUMENT_COMPLETED / DOCUMENT_SIGNEDcompleted
DOCUMENT_REJECTED / DOCUMENT_DECLINEDdeclined
DOCUMENT_CANCELLED / DOCUMENT_VOIDEDvoided
DOCUMENT_OPENEDviewed
DOCUMENT_SENTsent
DOCUMENT_EXPIREDexpired

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:

HeaderDescription
X-OpenInsure-SignatureHMAC-SHA256 hex digest of the body, prefixed with sha256=
X-OpenInsure-EventThe event type (e.g., invoice.paid)
X-OpenInsure-DeliveryUnique 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

VariableProviderPurpose
STRIPE_SECRET_KEYStripeSDK client initialization
STRIPE_WEBHOOK_SECRETStripeSignature verification
PERSONA_API_KEYPersonaClient initialization
PERSONA_WEBHOOK_SECRETPersonaSignature verification
RESEND_WEBHOOK_SECRETResendSvix signature verification
ESIGN_PROVIDERDocumenso / DocuSignProvider selection
ESIGN_API_KEYDocumenso / DocuSignProvider API key
ESIGN_WEBHOOK_SECRETDocumenso / DocuSignSignature verification
KV_CACHECloudflareIdempotency and replay protection
EVENTS_QUEUECloudflareAsync event processing
EMAIL_EVENTS_QUEUECloudflareResend 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.