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
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /stripe | Signature | Stripe webhook handler |
Stripe Webhook
Handles Stripe webhook events for subscription lifecycle management.
POST /api/webhooks/stripeHeaders:
Stripe-Signature: t=...,v1=...,v1=...
Content-Type: application/jsonSignature Verification
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
| Event | Action | Database Update |
|---|---|---|
checkout.session.completed | Link subscription to user | subscription_tier, subscription_status, stripe_customer_id |
customer.subscription.created | Update subscription state | subscription_tier, subscription_status |
customer.subscription.updated | Sync status changes | subscription_tier, subscription_status |
customer.subscription.deleted | Downgrade to free | subscription_tier → 'free', subscription_status → 'canceled' |
invoice.payment_succeeded | Acknowledged (no action) | None |
invoice.payment_failed | Mark payment failed | subscription_status → 'past_due' |
Event Flow
User Identification
The webhook handler uses multiple strategies to find the user:
- Primary:
user_idfrom session/subscription metadata (set by checkout endpoint) - Fallback: Email lookup from
customer_emailorcustomer_details.email - Subscription events: Lookup by
stripe_customer_id
// 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:
- Session metadata:
session.metadata.tier(from checkout endpoint) - Subscription lookup:
getTierFromPriceId(priceId)function
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 Status | App Status |
|---|---|
active | active |
past_due | past_due |
canceled | canceled |
unpaid | canceled |
trialing | trialing |
| Other | active |
Response
Success (always returns 200 to prevent retries):
{ "received": true }Signature Error:
{ "error": "Webhook Error: Signature verification failed" }Configuration Error:
{ "error": "Server configuration error" }Required Environment Variables
# 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
Configure in Stripe Dashboard:
- Endpoint URL:
https://app.podcasterplus.com/api/webhooks/stripe - Events to listen for:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_succeededinvoice.payment_failed
- API version: Latest
- Endpoint URL:
Store webhook secret:
bashwrangler 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:
# 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 testingSecurity 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
// 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
// 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-signatureheader - Invalid signature
Event Handling
checkout.session.completed:- Update via
user_idmetadata (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
- Update via
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')
- Downgrade to free tier (verifies
invoice.payment_failed:- Set status to past_due (verifies
subscription_status: 'past_due')
- Set status to past_due (verifies
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.completedupdated_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):
// 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:
.route('/webhooks/assemblyai', assemblyAIWebhook)Related
- Subscription Flow Guide - End-to-end flow documentation
- Stripe Integration - Full Stripe documentation
- Stripe API - Checkout and portal
- Supabase Admin - Admin client for RLS bypass