Stripe Webhooks Flow¶
This page explains how Stripe webhooks are handled by Workbench AI and how they update Supabase.
- Stripe is the source of truth for billing events.
- The
stripe-webhookedge function is the main entrypoint for webhook traffic. - Supabase tables like
subscriptions,payment_history, andwebhook_events_processedmaintain a local mirror.
Entry point: stripe-webhook edge function¶
File: supabase/functions/stripe-webhook/index.ts
Responsibilities:
- Accept HTTPS webhook requests from Stripe.
- Verify the
stripe-signatureheader usingSTRIPE_WEBHOOK_SECRET. - Enforce idempotency using the
webhook_events_processedtable. - Resolve
company_idfor the event. - Handle the event based on its
event.type:checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_succeeded- (others are logged as "Unhandled event type")
- Record the event outcome (success/error) into
webhook_events_processed.
If any error occurs after verification, the function logs it and returns a 500 response to Stripe.
Signature verification & idempotency¶
Signature verification¶
- The function reads the raw request body (
await req.text()) and thestripe-signatureheader. - It calls
stripe.webhooks.constructEventAsync(body, signature, STRIPE_WEBHOOK_SECRET). - If the signature is missing or invalid, it responds with an error (400/500) and does not touch the database.
Idempotency with webhook_events_processed¶
Before processing the event payload, the function checks:
const { data: existingEvent } = await supabase
.from('webhook_events_processed')
.select('event_id, status, processed_at')
.eq('event_id', event.id)
.maybeSingle()
- If a row is found, the webhook is treated as already processed:
- Logs that it is skipping the event.
- Returns 200 with
{ received: true, skipped: true }.
- If no existing row is found, processing continues and the event is recorded after successful handling via
recordEventProcessed(...).
This protects against retries or duplicate deliveries from Stripe.
Resolving company_id¶
Most webhook logic needs to know which company/tenant the event belongs to. stripe-webhook tries several sources in order:
-
Direct metadata
event.data.object.metadata.company_id(works for many Stripe objects).
-
Subscription metadata
- For
customer.subscription.*events, readssubscription.metadata.company_id.
- For
-
Checkout session metadata
- For
checkout.session.completed, readssession.metadata.company_id.
- For
-
Existing
subscriptionsrow- For events involving a subscription, uses
stripe_subscription_idto look upcompany_idin Supabase:
- For events involving a subscription, uses
const { data: existing } = await supabase
.from('subscriptions')
.select('company_id')
.eq('stripe_subscription_id', stripeSubscriptionId)
.maybeSingle()
- Customer metadata
- If still missing, fetches the Stripe
Customerand readscustomer.metadata.company_id.
- If still missing, fetches the Stripe
If company_id is still not found after all attempts, the function logs an error and returns 400 without updating any business tables. The event is still recorded as an error in webhook_events_processed.
Events handled¶
checkout.session.completed¶
When a user successfully completes a subscription checkout:
- The function retrieves the full
Stripe.Subscriptionreferenced bysession.subscription. - It determines the plan name from
session.metadata.plan_nameorsubscription.metadata.plan_name(defaults tostarterif absent). - It computes trial information using
getTrialData(subscription):- Prefers
subscription.trial_endif present. - Falls back to
subscription.created + 14 daysif Stripe trial data is missing.
- Prefers
- It builds a
subscriptionDatapayload:company_idplan_namestatus(mapped totrial,active, orpast_due)stripe_subscription_id,stripe_customer_idamount,currency,intervalcurrent_period_start,current_period_endcancel_at_period_end,canceled_attrial_end,trial_days_remaining
- It calls
updateSubscriptionWithLock(subscriptionData)to upsert the row insubscriptionswith race-condition protection.
customer.subscription.created / customer.subscription.updated¶
For subscription lifecycle events:
- The function reads the
Stripe.Subscriptionfromevent.data.object. - It determines
plan_nameand trial info via the same logic as checkout. - It builds
subscriptionDataand passes it toupdateSubscriptionWithLock:- Updates the existing row if one exists for the company.
- Otherwise upserts by
stripe_subscription_id.
This keeps the subscriptions table in sync with Stripe when status, period, or plan change.
invoice.payment_succeeded¶
When an invoice is paid:
- The function attempts to resolve the company:
- From the associated subscription (via
subscriptionstable or Stripe metadata). - From the customer metadata as a fallback.
- From the associated subscription (via
- If company cannot be resolved, it logs and skips inserting payment history for that invoice.
- If resolved, and if the invoice has not already been recorded:
- It inserts a row into
payment_historywith: company_idstripe_invoice_idamount,currencystatus(succeeded/paid)invoice_url(prefershosted_invoice_urlorinvoice_pdf)paid_at,created_attimestamps
- It inserts a row into
This table powers billing history views in the app.
customer.subscription.deleted¶
When a subscription is canceled:
- The function checks for an "upgrade cancellation" flag:
- If
subscription.metadata.upgrade_cancellation === 'true': - Treats it as a silent cancellation during an upgrade.
- Updates
subscriptionswithstatus = 'canceled',canceled_at = now, but does not trigger user-facing cancellation behavior. - Otherwise (real cancellation):
- Updates
subscriptionsfor the matchingstripe_subscription_idtostatus = 'canceled'and recordscanceled_at.
- If
Default / unhandled events¶
Any event type not explicitly handled falls into the default branch:
- Logs
"Unhandled event type, skipping"withevent.type. - Marks the event as
successinwebhook_events_processedwithout modifying business tables.
Updating subscriptions safely¶
updateSubscriptionWithLock(subscriptionData) implements a defensive upsert strategy:
-
Find an existing subscription row
- Looks up
subscriptionsbycompany_id, ordering bycreated_atdescending.
- Looks up
-
If an existing row has
nullstripe_subscription_id- Directly updates that row with
subscriptionData(fills in the Stripe ID).
- Directly updates that row with
-
Otherwise, try upsert with
onConflict: 'stripe_subscription_id'- Inserts or updates based on
stripe_subscription_id.
- Inserts or updates based on
-
Fallback
- If upsert fails but an existing row exists:
- Updates that row by
company_id+id. - Refetches the updated row to return consistent data.
This reduces the chance of duplicate subscription rows or race conditions during rapid event sequences.
Recording outcomes (webhook_events_processed)¶
After processing (or skipping) an event, recordEventProcessed writes a row:
event_id– Stripe event ID.event_type– e.g.checkout.session.completed.company_id– resolved company ID (if available).subscription_id– primary Stripe object ID for the event.status–'success' | 'error' | 'skipped'.error_message– if an error occurred.metadata– additional details (e.g. event_type, company_id).
On success, this is mostly used for audit and debugging. On error, it provides a trail to investigate failed events.
End-to-end webhook flow (diagram)¶
flowchart LR
ST[Stripe] --> WH[Webhook HTTPS POST]
WH --> EF["stripe-webhook (Edge Function)"]
EF --> IDEMP[webhook_events_processed]
EF --> SUBS[(subscriptions)]
EF --> PAY[(payment_history)]
click EF "../api/edge-functions/" "stripe-webhook edge function"
click SUBS "../architecture/supabase/" "Subscriptions table in Supabase"
click PAY "../architecture/supabase/" "Payment history tables"
click IDEMP "../architecture/supabase/" "Webhook idempotency tracking"
click ST "./billing-stripe/" "Billing & Stripe flow"
Narrative:
- Stripe sends a webhook request for an event.
stripe-webhookverifies the signature and checks idempotency.- It resolves
company_idand handles the event type. - It updates
subscriptionsandpayment_historyas needed. - It records the outcome in
webhook_events_processed. - The frontend reads from Supabase (via
useSubscriptionand related hooks) to decide what the user can access.
Where to look next¶
- For overall billing flows and UI triggers: Billing & Stripe Flow
- For how subscriptions are read and enforced in the app: Auth Flow and Frontend Architecture
- For table-level details: Supabase Architecture and the API/Data section.