Skip to content

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-webhook edge function is the main entrypoint for webhook traffic.
  • Supabase tables like subscriptions, payment_history, and webhook_events_processed maintain a local mirror.

Entry point: stripe-webhook edge function

File: supabase/functions/stripe-webhook/index.ts

Responsibilities:

  1. Accept HTTPS webhook requests from Stripe.
  2. Verify the stripe-signature header using STRIPE_WEBHOOK_SECRET.
  3. Enforce idempotency using the webhook_events_processed table.
  4. Resolve company_id for the event.
  5. Handle the event based on its event.type:
    • checkout.session.completed
    • customer.subscription.created
    • customer.subscription.updated
    • customer.subscription.deleted
    • invoice.payment_succeeded
    • (others are logged as "Unhandled event type")
  6. 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 the stripe-signature header.
  • 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:

  1. Direct metadata

    • event.data.object.metadata.company_id (works for many Stripe objects).
  2. Subscription metadata

    • For customer.subscription.* events, reads subscription.metadata.company_id.
  3. Checkout session metadata

    • For checkout.session.completed, reads session.metadata.company_id.
  4. Existing subscriptions row

    • For events involving a subscription, uses stripe_subscription_id to look up company_id in Supabase:
const { data: existing } = await supabase
  .from('subscriptions')
  .select('company_id')
  .eq('stripe_subscription_id', stripeSubscriptionId)
  .maybeSingle()
  1. Customer metadata
    • If still missing, fetches the Stripe Customer and reads customer.metadata.company_id.

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.Subscription referenced by session.subscription.
  • It determines the plan name from session.metadata.plan_name or subscription.metadata.plan_name (defaults to starter if absent).
  • It computes trial information using getTrialData(subscription):
    • Prefers subscription.trial_end if present.
    • Falls back to subscription.created + 14 days if Stripe trial data is missing.
  • It builds a subscriptionData payload:
    • company_id
    • plan_name
    • status (mapped to trial, active, or past_due)
    • stripe_subscription_id, stripe_customer_id
    • amount, currency, interval
    • current_period_start, current_period_end
    • cancel_at_period_end, canceled_at
    • trial_end, trial_days_remaining
  • It calls updateSubscriptionWithLock(subscriptionData) to upsert the row in subscriptions with race-condition protection.

customer.subscription.created / customer.subscription.updated

For subscription lifecycle events:

  • The function reads the Stripe.Subscription from event.data.object.
  • It determines plan_name and trial info via the same logic as checkout.
  • It builds subscriptionData and passes it to updateSubscriptionWithLock:
    • 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 subscriptions table or Stripe metadata).
    • From the customer metadata as a fallback.
  • 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_history with:
    • company_id
    • stripe_invoice_id
    • amount, currency
    • status (succeeded / paid)
    • invoice_url (prefers hosted_invoice_url or invoice_pdf)
    • paid_at, created_at timestamps

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 subscriptions with status = 'canceled', canceled_at = now, but does not trigger user-facing cancellation behavior.
    • Otherwise (real cancellation):
    • Updates subscriptions for the matching stripe_subscription_id to status = 'canceled' and records canceled_at.

Default / unhandled events

Any event type not explicitly handled falls into the default branch:

  • Logs "Unhandled event type, skipping" with event.type.
  • Marks the event as success in webhook_events_processed without modifying business tables.

Updating subscriptions safely

updateSubscriptionWithLock(subscriptionData) implements a defensive upsert strategy:

  1. Find an existing subscription row

    • Looks up subscriptions by company_id, ordering by created_at descending.
  2. If an existing row has null stripe_subscription_id

    • Directly updates that row with subscriptionData (fills in the Stripe ID).
  3. Otherwise, try upsert with onConflict: 'stripe_subscription_id'

    • Inserts or updates based on stripe_subscription_id.
  4. 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:

  1. Stripe sends a webhook request for an event.
  2. stripe-webhook verifies the signature and checks idempotency.
  3. It resolves company_id and handles the event type.
  4. It updates subscriptions and payment_history as needed.
  5. It records the outcome in webhook_events_processed.
  6. The frontend reads from Supabase (via useSubscription and related hooks) to decide what the user can access.

Where to look next