How the Stripe Billing Webhook Handles checkout.session.completed and Subscription Events

The Stripe billing webhook in api/billing/webhook.js verifies webhook signatures, routes events by type, and updates Supabase records to grant credits for completed checkouts and manage subscription lifecycles.

The checkout.session.completed event and subscription events (customer.subscription.created, customer.subscription.updated, customer.subscription.deleted) form the backbone of payment processing in the Awesome GPT Image 2 application. This article examines the exact implementation in freestylefly/awesome-gpt-image-2, tracing how the webhook translates Stripe events into database state changes.


Webhook Architecture and Verification

The billing webhook operates as a single serverless endpoint that Stripe calls for every payment-related event. Before processing any business logic, the implementation enforces cryptographic verification to prevent replay attacks and forged requests.

Signature Validation

In api/billing/webhook.js (line 121), the webhook extracts the raw request body and Stripe-Signature header, passing both to stripe.webhooks.constructEvent:

const event = stripe.webhooks.constructEvent(
  rawBody,
  signature,
  process.env.STRIPE_WEBHOOK_SECRET
);

This function throws if the signature does not match the payload, immediately rejecting unauthorized requests. Only verified events proceed to the event router — a switch statement that branches on event.type to handle each Stripe event category.


Handling checkout.session.completed Events

When a user completes payment through Stripe Checkout — whether for a one-time credit pack or a subscription — Stripe emits checkout.session.completed. The webhook processes this between lines 150-180 of webhook.js.

Data Extraction and User Identification

The handler retrieves the session object from event.data.object and extracts two critical fields:

  • client_reference_id — The Supabase user UUID passed during Checkout Session creation
  • metadata.plan_id — The identifier for the purchased plan or credit pack

Database Updates

The webhook executes two primary database operations:

  1. Credit allocation — Calls the Supabase RPC update_user_credits (defined in supabase/migrations/202605090001_user_credits.sql) to increment the user's credit balance

  2. Order recording — Inserts a permanent record into the stripe_order table, with schema defined in supabase/migrations/20260509090000_membership_billing.sql

Post-Processing

After successful database updates, the webhook triggers confirmation email delivery via the sendEmail helper and logs the completed transaction for audit purposes.

// Conceptual flow for checkout.session.completed
async function handleCheckoutCompleted(event) {
  const session = event.data.object;
  const userId = session.client_reference_id;
  const planId = session.metadata.plan_id;

  await supabase.rpc('update_user_credits', {
    p_user_id: userId,
    p_plan_id: planId
  });

  await supabase.from('stripe_order').insert({
    stripe_session_id: session.id,
    user_id: userId,
    plan_id: planId,
    amount: session.amount_total
  });

  await sendEmail(userId, 'purchase_confirmation', { planId });
}

Processing Subscription Lifecycle Events

Subscription management relies on three Stripe event types handled in lines 200-260 of webhook.js. All subscription operations interact with the stripe_subscription table and membership view defined in the migration 20260509090000_membership_billing.sql.

customer.subscription.created

When a new subscription starts, the webhook:

  • Creates a record in stripe_subscription with the Stripe subscription ID, customer ID, and initial status
  • Marks the user as active in the membership system
  • Associates the subscription with the correct plan tier

customer.subscription.updated

This event fires for any subscription change — payment method updates, plan upgrades, or status transitions. The handler inspects status and branches accordingly:

Status Webhook Action
active Refresh current_period_end timestamp; ensure premium benefits remain active
past_due Log payment issue; typically allows grace period before downgrade
canceled or unpaid Downgrade user plan; revoke subscription-only premium credits

The status-checking logic determines whether to preserve or remove subscription entitlements based on the new state.

customer.subscription.deleted

When a subscription is fully canceled and reaches period end, Stripe emits this event. The webhook:

  • Removes or archives the subscription record from stripe_subscription
  • Deactivates premium membership status
  • Reverts the user to free-tier limitations

This ensures immediate enforcement of subscription expiration without relying on polling or cron jobs.

// Simplified subscription update handler
async function handleSubscriptionUpdated(event) {
  const subscription = event.data.object;
  const status = subscription.status;

  if (status === 'canceled' || status === 'unpaid') {
    await supabase.rpc('downgrade_user_plan', {
      p_stripe_subscription_id: subscription.id
    });
  } else if (status === 'active') {
    await supabase.rpc('refresh_subscription_period', {
      p_stripe_subscription_id: subscription.id,
      p_period_end: subscription.current_period_end
    });
  }
}

Error Handling and Resilience

The webhook implements fail-safe error handling at line 147. Any exception — whether from signature verification failure or database operation error — triggers a warning log:

console.warn('Failed to process Stripe webhook', { error });

This pattern ensures that:

  • Invalid signatures return appropriate error responses without database side effects
  • Transient database failures are logged for monitoring without crashing the endpoint
  • Stripe's retry mechanism can resubmit failed events for eventual processing

Critical operations use idempotent RPCs where possible, preventing duplicate credit grants or duplicate subscription records on retry.


Testing the Webhook Locally

Developers can simulate Stripe events using the Stripe CLI:


# Forward webhook events to local development server

stripe listen --forward-to http://localhost:3000/api/billing/webhook

# Trigger specific event types

stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted

For unit testing, individual handlers can be imported and invoked with mock payloads:

import { handleCheckoutCompleted } from '@/api/billing/webhook';

const mockEvent = {
  type: 'checkout.session.completed',
  data: {
    object: {
      id: 'cs_test_123',
      client_reference_id: 'user-uuid-abc',
      metadata: { plan_id: 'credit_pack_1000' },
      amount_total: 1000,
      currency: 'usd'
    }
  }
};

await handleCheckoutCompleted(mockEvent);

Key Source Files

File Purpose
api/billing/webhook.js Main webhook endpoint (lines 121, 147, 150-180, 200-260) — signature verification, event routing, database updates
supabase/migrations/20260509090000_membership_billing.sql Schema for stripe_order, stripe_subscription, and membership RPCs
supabase/migrations/202605090001_user_credits.sql update_user_credits RPC for credit allocation
api/billing/utils.js Email helpers and shared Supabase client utilities

Summary

  • Signature verification via stripe.webhooks.constructEvent (line 121) guards all downstream processing
  • checkout.session.completed grants credits via update_user_credits RPC and records orders in stripe_order
  • Subscription events in lines 200-260 handle creation, status changes, and deletion with tier-aware upscaling and downscaling
  • Error handling at line 147 prevents malformed requests from affecting database state
  • All database operations use RPCs defined in migration 20260509090000_membership_billing.sql

Frequently Asked Questions

How does the webhook verify that requests actually come from Stripe?

The webhook uses stripe.webhooks.constructEvent at line 121, which cryptographically validates the request body against the Stripe-Signature header using the STRIPE_WEBHOOK_SECRET environment variable. Requests with invalid signatures throw errors and are rejected before any business logic executes.

What happens if a subscription payment fails and enters past_due status?

The customer.subscription.updated handler checks the status field. For past_due, the webhook typically logs the issue while maintaining benefits temporarily. If the status transitions to canceled or unpaid, the webhook calls a downgrade RPC to revoke premium features and subscription-only credits.

Can the webhook grant credits for both one-time purchases and subscriptions?

Yes. The checkout.session.completed handler processes both payment types. It extracts metadata.plan_id to determine credit quantity, then calls update_user_credits accordingly. Subscription creation separately updates the stripe_subscription table for ongoing entitlement management.

How can I test these webhook handlers without making real payments?

Use the Stripe CLI to forward events locally (stripe listen --forward-to), then trigger specific event types with stripe trigger. For automated testing, import individual handler functions and invoke them with mock event objects containing the required event.data.object structure.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →