Skip to content

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.

POST https://visibilityiq365.com/api/billing/webhook

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

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 signature with 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.

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 — this event.id was 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.

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

EventWhat it changes in your account
customer.subscription.updatedRe-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.deletedLands with status canceled. canceled is not an entitling status, so access is revoked once the paid period ends.
invoice.payment_succeededThe 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_failedRe-mirrors the subscription, which typically now reads past_due or unpaid. The entitlement gate stops granting access once the paid period lapses.

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.

The entitlement rule is:

status IN ('active', 'trialing') AND current_period_end > now

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

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.