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

> Learn how the Stripe billing webhook handles checkout session completed and subscription events. This guide explains event routing, signature verification, and Supabase record updates for credit grants and subscription management.

- Repository: [苍何/awesome-gpt-image-2](https://github.com/freestylefly/awesome-gpt-image-2)
- Tags: how-to-guide
- Published: 2026-09-02

---

**The Stripe billing webhook in [`api/billing/webhook.js`](https://github.com/freestylefly/awesome-gpt-image-2/blob/main/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`](https://github.com/freestylefly/awesome-gpt-image-2/blob/main/api/billing/webhook.js) (line 121), the webhook extracts the raw request body and `Stripe-Signature` header, passing both to `stripe.webhooks.constructEvent`:

```javascript
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`](https://github.com/freestylefly/awesome-gpt-image-2/blob/main/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`](https://github.com/freestylefly/awesome-gpt-image-2/blob/main/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`](https://github.com/freestylefly/awesome-gpt-image-2/blob/main/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.

```javascript
// 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`](https://github.com/freestylefly/awesome-gpt-image-2/blob/main/webhook.js). All subscription operations interact with the **`stripe_subscription`** table and **`membership`** view defined in the migration [`20260509090000_membership_billing.sql`](https://github.com/freestylefly/awesome-gpt-image-2/blob/main/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.

```javascript
// 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:

```javascript
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**:

```bash

# 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:

```javascript
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`](https://github.com/freestylefly/awesome-gpt-image-2/blob/main/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`](https://github.com/freestylefly/awesome-gpt-image-2/blob/main/supabase/migrations/20260509090000_membership_billing.sql) | Schema for `stripe_order`, `stripe_subscription`, and membership RPCs |
| [`supabase/migrations/202605090001_user_credits.sql`](https://github.com/freestylefly/awesome-gpt-image-2/blob/main/supabase/migrations/202605090001_user_credits.sql) | `update_user_credits` RPC for credit allocation |
| [`api/billing/utils.js`](https://github.com/freestylefly/awesome-gpt-image-2/blob/main/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`](https://github.com/freestylefly/awesome-gpt-image-2/blob/main/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.