Skip to content

Webhooks API

Handles incoming webhooks from external services.

Base Path: /api/webhooks

Authentication: Signature verification (not Bearer token).

Source Files:

  • src/api/routes/webhooks/stripe.ts

Endpoints

MethodPathAuthDescription
POST/stripeSignatureStripe webhook handler

Stripe Webhook

Handles Stripe webhook events for subscription lifecycle management.

POST /api/webhooks/stripe

Headers:

Stripe-Signature: t=...,v1=...,v1=...
Content-Type: application/json

Signature Verification

typescript
import Stripe from 'stripe';

const stripe = new Stripe(STRIPE_SECRET_KEY);
const sig = c.req.header('stripe-signature');
const body = await c.req.text(); // Must use raw text, not JSON

const event = stripe.webhooks.constructEvent(body, sig, STRIPE_WEBHOOK_SECRET);

Handled Events

EventActionDatabase Update
checkout.session.completedLink subscription to usersubscription_tier, subscription_status, stripe_customer_id
customer.subscription.createdUpdate subscription statesubscription_tier, subscription_status
customer.subscription.updatedSync status changessubscription_tier, subscription_status
customer.subscription.deletedDowngrade to freesubscription_tier → 'free', subscription_status → 'canceled'
invoice.payment_succeededAcknowledged (no action)None
invoice.payment_failedMark payment failedsubscription_status → 'past_due'

Event Flow

User Identification

The webhook handler uses multiple strategies to find the user:

  1. Primary: user_id from session/subscription metadata (set by checkout endpoint)
  2. Fallback: Email lookup from customer_email or customer_details.email
  3. Subscription events: Lookup by stripe_customer_id
typescript
// checkout.session.completed
const userId = session.metadata?.user_id;          // Primary
const customerEmail = session.customer_email;     // Fallback

if (userId) {
  await supabase.from('user_profiles').update({ ... }).eq('id', userId);
} else if (customerEmail) {
  await supabase.from('user_profiles').update({ ... }).eq('email', customerEmail.toLowerCase());
}

// subscription.updated/deleted
await supabase.from('user_profiles').update({ ... }).eq('stripe_customer_id', customerId);

Tier Detection

Tier is determined through multiple strategies:

  1. Session metadata: session.metadata.tier (from checkout endpoint)
  2. Subscription lookup: getTierFromPriceId(priceId) function
typescript
let tier = session.metadata?.tier || 'starter';

// If no tier in metadata, fetch from subscription
if (subscriptionId && !session.metadata?.tier) {
	const subscription = await stripe.subscriptions.retrieve(subscriptionId);
	const priceId = subscription.items.data[0]?.price.id;
	tier = getTierFromPriceId(priceId);
}

See Supabase Admin for getTierFromPriceId() details.

Subscription Status Mapping

Stripe statuses are mapped to app statuses:

Stripe StatusApp Status
activeactive
past_duepast_due
canceledcanceled
unpaidcanceled
trialingtrialing
Otheractive

Response

Success (always returns 200 to prevent retries):

json
{ "received": true }

Signature Error:

json
{ "error": "Webhook Error: Signature verification failed" }

Configuration Error:

json
{ "error": "Server configuration error" }

Required Environment Variables

bash
# Stripe API authentication
STRIPE_SECRET_KEY=sk_...

# Webhook signature verification (primary endpoint)
STRIPE_WEBHOOK_SECRET=whsec_...
# Optional SECOND signing secret (Epic 16 GATE-3): the my.show.fm webhook
# endpoint has its own secret; verification tries the primary, then this one.
# Without it, events signed by the show.fm endpoint return 400.
STRIPE_WEBHOOK_SECRET_SHOWFM=whsec_...

# Supabase admin access (for bypassing RLS)
PUBLIC_SUPABASE_URL=https://[project].supabase.co
SUPABASE_SECRET_KEY=sb_secret_...
# or legacy: SUPABASE_SERVICE_ROLE_KEY=eyJ...

Webhook Configuration

Production Setup

  1. Configure in Stripe Dashboard:

    • Endpoint URL: https://app.podcasterplus.com/api/webhooks/stripe
    • Events to listen for:
      • checkout.session.completed
      • customer.subscription.created
      • customer.subscription.updated
      • customer.subscription.deleted
      • invoice.payment_succeeded
      • invoice.payment_failed
    • API version: Latest
  2. Store webhook secret:

    bash
    wrangler secret put STRIPE_WEBHOOK_SECRET

Dual-host transition (Epic 16 GATE-3 → GATE-11)

While the app answers on both app.podcasterplus.com and my.show.fm, Stripe has TWO webhook endpoints (same event list, one per host), each with its own signing secret. The handler verifies against STRIPE_WEBHOOK_SECRET first and falls back to STRIPE_WEBHOOK_SECRET_SHOWFM, so either endpoint's events verify. Setup order: add the https://my.show.fm/api/webhooks/stripe endpoint in the Stripe dashboard, then wrangler secret put STRIPE_WEBHOOK_SECRET_SHOWFM with its secret — an enabled endpoint whose secret is not provisioned has its events rejected with 400 until Stripe retries. At the GATE-11 flip the old endpoint is disabled in the dashboard; the code needs no change.

Local Development

Use Stripe CLI to forward webhooks:

bash
# Install Stripe CLI
brew install stripe/stripe-cli/stripe

# Login
stripe login

# Forward webhooks to local server
stripe listen --forward-to localhost:5173/api/webhooks/stripe

# Use the provided webhook signing secret for local testing

Security Considerations

  • Signature Verification: All webhooks must pass signature verification before processing
  • Idempotency: Events may be delivered multiple times; handlers are idempotent
  • Raw Body: Must use raw request body (c.req.text()) for signature verification
  • Timing: Respond within 30 seconds to avoid retries
  • Error Handling: Return 200 even on database errors to prevent infinite retries

Test Coverage

src/api/routes/webhooks/__tests__/stripe.test.ts:

Tests use behavior assertions to verify actual data written to the database, not just that methods were called. This approach catches real bugs like incorrect field values or missing updates.

Mock Architecture

typescript
// Hoisted mocks for argument capture
const { mockSupabaseUpdate, mockSupabaseFrom } = vi.hoisted(() => ({
  mockSupabaseUpdate: vi.fn(),
  mockSupabaseFrom: vi.fn()
}));

// Mock with mockImplementation to capture arguments
vi.mock('$lib/supabase/admin', () => ({
  createAdminClient: vi.fn(() => ({
    from: mockSupabaseFrom.mockImplementation((table) => ({
      update: mockSupabaseUpdate.mockImplementation((data) => ({
        eq: vi.fn().mockImplementation(() => ({
          select: vi.fn().mockResolvedValue({ data: [...], error: null })
        }))
      }))
    }))
  }))
}));

Behavior Assertions Example

typescript
// Verify which table was accessed
expect(mockSupabaseFrom).toHaveBeenCalledWith('user_profiles');

// Verify exact data written (not just that update was called)
expect(mockSupabaseUpdate).toHaveBeenCalledWith(
	expect.objectContaining({
		subscription_tier: 'starter',
		subscription_status: 'active',
		stripe_customer_id: 'cus_test_123'
	})
);

Configuration Validation

  • Missing STRIPE_SECRET_KEY
  • Missing STRIPE_WEBHOOK_SECRET
  • Missing PUBLIC_SUPABASE_URL
  • Missing Supabase admin key (both new and legacy)

Signature Verification

  • Missing stripe-signature header
  • Invalid signature

Event Handling

  • checkout.session.completed:

    • Update via user_id metadata (verifies tier, status, customer_id)
    • Fallback to email lookup
    • Tier from metadata vs subscription lookup
    • Professional tier from metadata
    • Handle missing user_id and email gracefully
  • customer.subscription.created:

    • Update user profile by customer ID (verifies tier, status)
  • customer.subscription.updated:

    • Status mapping (active, past_due, canceled, trialing)
    • Verifies correct status values written
  • customer.subscription.deleted:

    • Downgrade to free tier (verifies subscription_tier: 'free', subscription_status: 'canceled')
  • invoice.payment_failed:

    • Set status to past_due (verifies subscription_status: 'past_due')
  • invoice.payment_succeeded:

    • Acknowledge without action

Database Fields Updated

The webhook handler writes these fields to user_profiles:

  • subscription_tier - The subscription tier (free, starter, professional)
  • subscription_status - The subscription status (active, canceled, past_due, trialing)
  • stripe_customer_id - Only on checkout.session.completed
  • updated_at - Timestamp of the update

Note: stripe_subscription_id is NOT stored by the webhook handler.

Error Handling

  • Database update failures (returns 200 to prevent retries)
  • Unhandled event types (logs and acknowledges)

Security

  • Signature verification before processing
  • Legacy service role key support

Adding New Webhooks

To add webhooks for other services (e.g., AssemblyAI):

typescript
// src/api/routes/webhooks/assemblyai.ts
import { Hono } from 'hono';

export const assemblyAIWebhook = new Hono<{ Bindings: Bindings }>().post('/', async (c) => {
	// Verify webhook signature
	const signature = c.req.header('X-Assembly-Signature');
	// ... verification logic

	const event = await c.req.json();

	switch (event.status) {
		case 'completed':
			// Handle transcription complete
			break;
		case 'error':
			// Handle transcription error
			break;
	}

	return c.json({ received: true });
});

Mount in src/api/index.ts:

typescript
.route('/webhooks/assemblyai', assemblyAIWebhook)

Internal documentation - Not for public distribution