Stripe billing webhooks
VisibilityIQ subscriptions are billed through Stripe. Stripe owns the schedule, retries, SCA, proration, and dunning; VisibilityIQ keeps a small mirror of each subscription in D1 so it can gate access without calling Stripe on every request. That mirror is kept current by a single Stripe webhook.
This page documents the inbound Stripe → VisibilityIQ webhook. It is distinct from the outbound VisibilityIQ webhooks that notify your server when an audit completes. You do not register this endpoint — it is part of the platform. The page exists so you understand exactly what changes in your account when a payment event lands.
The endpoint
Section titled “The endpoint”POST https://visibilityiq365.com/api/billing/webhookThis is a public route (whitelisted in middleware) that self-gates by verifying the Stripe signature. It is registered against your Stripe account once at go-live and points at the live Worker.
Signature verification
Section titled “Signature verification”The handler reads the raw request body — never the JSON-parsed body — because the
signature is computed over the exact bytes Stripe sent. It verifies the
stripe-signature header against the STRIPE_WEBHOOK_SECRET using Stripe’s
constructEventAsync with a WebCrypto provider. (The synchronous constructEvent
reaches for Node crypto and fails on Cloudflare Workers, so the async path is
mandatory.)
- An invalid or missing signature returns
400 Invalid signaturewith no detail leaked — the verifier’s message is never echoed to the caller. - The signing secret and the signature header are never logged.
A successful verification yields the typed Stripe.Event, which is then processed
idempotently.
Idempotency
Section titled “Idempotency”Stripe delivers at least once, so replays happen. Before applying any side effect, the
handler claims event.id in the stripe_webhook_events table (the event id is the
primary key). A replayed event collides on that key, the claim returns false, and the
handler no-ops with a 2xx. The response body reports the outcome for observability:
{ "received": true, "outcome": "applied" }outcome is one of:
applied— a mirror-updating event was processed.duplicate— thisevent.idwas already claimed; nothing changed.ignored— the event verified but is not one VisibilityIQ acts on.
A 500 Webhook processing failed is returned only on a transient D1/Stripe error, so
Stripe retries; Stripe re-emits authoritative subscription state, so the next event
re-drives the mirror.
The four authoritative events
Section titled “The four authoritative events”VisibilityIQ updates the mirror from exactly four event types. Every other event
type verifies and returns 2xx with outcome: "ignored" — including
checkout.session.completed, which the platform does not act on (the subscription
events below carry the authoritative state instead).
| Event | What it changes in your account |
|---|---|
customer.subscription.updated | Re-mirrors status, price, current_period_end, and cancel_at_period_end. This is how a plan change, a scheduled cancellation, or a renewal becomes visible to the entitlement gate. |
customer.subscription.deleted | Lands with status canceled. canceled is not an entitling status, so access is revoked once the paid period ends. |
invoice.payment_succeeded | The handler retrieves the live subscription (items expanded) and re-mirrors it — most importantly the new current_period_end, extending access through the next paid period. |
invoice.payment_failed | Re-mirrors the subscription, which typically now reads past_due or unpaid. The entitlement gate stops granting access once the paid period lapses. |
Why invoices re-fetch the subscription
Section titled “Why invoices re-fetch the subscription”An invoice.payment_* event does not itself carry the live period end. The handler
extracts the subscription id from the invoice (invoice.parent.subscription_details
under the pinned 2026-05-27.dahlia API version) and retrieves the subscription
with items expanded — Stripe stays the source of truth. The period end is read off the
subscription items (dahlia moved current_period_end onto the item), and the
minimum across items is mirrored so entitlement never outlives the soonest-ending
item. An invoice unrelated to a subscription is ignored.
How the mirror gates access
Section titled “How the mirror gates access”The entitlement rule is:
status IN ('active', 'trialing') AND current_period_end > nowSo active and trialing grant access through the paid-for period; past_due,
unpaid, and canceled do not. A canceled subscription that still has paid time left
keeps access until current_period_end, because Stripe leaves it active until that
moment — the customer.subscription.deleted event only flips it to canceled when the
period actually closes.
Example payload
Section titled “Example payload”A customer.subscription.updated event (trimmed to the fields the handler reads):
{ "id": "evt_1PabcXYZ", "type": "customer.subscription.updated", "data": { "object": { "id": "sub_1Pxyz123", "object": "subscription", "status": "active", "customer": "cus_QabcdEFGH", "cancel_at_period_end": false, "metadata": { "organizationId": "org_abc123", "userId": "user_def456" }, "items": { "data": [ { "id": "si_1Pqrs", "current_period_end": 1786531200, "price": { "id": "price_1Pmonthly" } } ] } } }}The organizationId and userId are written to the subscription metadata at checkout,
so the webhook resolves scope without trusting the client. When metadata is absent (for
example on an invoice-driven update), the handler resolves the organization from the
existing mirror row keyed by stripeCustomerId, falling back to a status-only update —
it never blanks out the organization and never creates an orphan, un-scoped row.
Related
Section titled “Related”- VisibilityIQ webhooks — outbound
audit.completed/audit.failednotifications to your server. - Audit webhooks — audit-specific event payloads.
- Full API reference — auth, scopes, and credit billing.