Knowledge > Processes > Stripe Webhook Processing
Stripe Webhook Processing
Three separate webhook handlers exist -- one per deployed property. All share the same Stripe account but process different product events. Stripe sends HTTP POST requests to each endpoint when subscription lifecycle events occur.
Overview: Which handler owns what
| Handler | URL | Products handled |
|---|---|---|
| ChurchWiseAI | churchwiseai.com/api/stripe/webhook | Voice Agent, Chatbot tiers (Starter/Pro/Suite), Bundles, AI Starter Kit, SermonWise Pro, ShareWiseAI |
| PewSearch | pewsearch.com/api/stripe/webhook | Premium Page ($9.95), Pro Website ($19.95) |
| IllustrateTheWord | illustratetheword.com/api/stripe/webhook | ITW Premium ($9.95/mo, $99.50/yr) |
All three endpoints listen for the same five Stripe event types:
checkout.session.completedcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_failedcustomer.subscription.trial_will_end
Step 1: Receive and verify the request
All three handlers follow the same verification pattern:
WHEN Stripe sends a POST request to /api/stripe/webhook:
1. Read the raw request body as text (not JSON -- needed for signature verification)
2. Extract the "stripe-signature" header
3. IF no signature header:
RETURN 400 "Missing stripe-signature header"
4. Call stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET)
5. IF signature verification fails:
LOG the error
RETURN 400 "Webhook verification failed"
6. PROCEED to event routing
The STRIPE_WEBHOOK_SECRET is a per-endpoint secret (whsec_...) stored as a Vercel environment variable. Each property has its own webhook signing secret.
ITW extra: The ITW handler also applies a strict rate limit (100 requests per 60 seconds per IP) before signature verification.
Step 2: Route by event type
Event: checkout.session.completed
This is the primary activation event. It fires when a customer completes Stripe Checkout.
ChurchWiseAI handler
The CWA handler processes multiple product types from a single event, distinguished by session.metadata:
A. Church AI subscription (Voice Agent, Chatbot, Bundles):
WHEN metadata has church_id AND customer AND subscription:
1. Extract: churchId, tier (starter/pro/suite/voice_starter/voice_pro/bundle), customerId, subscriptionId
2. IF any of churchId, customerId, or subscriptionId is missing:
LOG error and STOP
3. Call activateChurch(churchId, customerId, subscriptionId, tier)
(See "activateChurch deep dive" below)
4. Sync subscriber to MailerLite "cwa-customers" group (non-blocking, fire-and-forget)
B. AI Starter Kit ($4.95 one-time purchase):
WHEN metadata.product = "ai_starter_kit":
1. Get customer email from session.customer_details.email
2. Send starter kit delivery email
3. Add to MailerLite "starter-kit" group (non-blocking)
C. SermonWise Pro subscription:
WHEN metadata.product = "sermon_pro" AND metadata.user_id exists:
1. Update profiles table: SET subscription_tier = "sermon_pro"
2. Store stripe_customer_id on profiles row
3. Add to MailerLite "sermonwise-pro" group (non-blocking)
D. ShareWiseAI subscription:
WHEN metadata.product = "social" AND metadata.user_id exists:
1. Update social_subscriptions table: SET tier, status = "active", stripe IDs
2. Add to MailerLite "sharewise-paid" group (non-blocking)
Note: CWA intentionally does NOT handle customer.subscription.created -- all CWA subscriptions go through Stripe Checkout, so checkout.session.completed is the single activation event. Handling both would cause duplicate welcome emails.
PewSearch handler
WHEN checkout.session.completed fires:
1. Extract churchId and premiumId from session.metadata
2. Extract tier (starter/pro/suite/pro_website, default: starter)
3. IF churchId or premiumId missing: LOG error and STOP
4. Validate that premiumId belongs to churchId (prevents metadata tampering)
5. IF tier is a CWA tier (chatbot_starter, voice_starter, etc.):
SKIP -- those are handled by churchwiseai.com's webhook
6. Call activateChurch(churchId, customerId, subscriptionId, tier)
PewSearch ALSO handles customer.subscription.created as a backup activation path (in case checkout.session.completed is missed), with the same CWA-tier filtering.
IllustrateTheWord handler
WHEN checkout.session.completed fires:
1. Extract userId from session.metadata.supabase_user_id
2. Retrieve the full subscription from Stripe API
3. Look up the Stripe price ID in the pricing_tiers table to find the pricing_tier_id
4. Call upsertSubscription() to create or update the user_subscriptions record
Fields: user_id, stripe_customer_id, stripe_subscription_id, status, pricing_tier_id, current_period_end
ITW uses Supabase Auth (not token-based), so it stores subscriptions against user IDs in a user_subscriptions table, not premium_churches.
Event: customer.subscription.updated
Fires when a subscription changes status (e.g., payment retry succeeds, plan upgrade, trial ends).
ChurchWiseAI handler
WHEN subscription status changes:
1. Look up premium_churches by stripe_subscription_id
2. IF not found: LOG error and STOP
-- Church AI products:
3. IF status = "past_due" OR "unpaid":
SET premium_churches.status = "expired"
SET churches.is_premium = false
4. IF status = "active":
Read new tier from subscription.metadata.tier
Normalize via normalizePlanKey() to get plan + channel
SET premium_churches.status = "active", update plan/channel
SET churches.is_premium = true
-- SermonWise Pro (if metadata.product = "sermon_pro"):
5. IF past_due/unpaid: SET profiles.subscription_tier = "free"
6. IF active: SET profiles.subscription_tier = "sermon_pro"
-- ShareWiseAI (if metadata.product = "social"):
7. IF past_due/unpaid: SET social_subscriptions.status = "past_due"
8. IF active: SET tier from metadata, SET status = "active"
PewSearch handler
WHEN subscription status changes:
1. Look up premium_churches by stripe_subscription_id
2. IF not found: LOG error and STOP
3. IF status = "past_due" OR "unpaid":
SET premium_churches.status = "expired"
4. IF status = "active":
Read new tier from metadata
SET status = "active", optionally update plan
IllustrateTheWord handler
WHEN subscription status changes:
1. Look up user_subscriptions by stripe_customer_id
2. IF found: call upsertSubscription() with new status and period end
Event: customer.subscription.deleted
Fires when a subscription is cancelled (immediately or at period end).
ChurchWiseAI handler
WHEN subscription is deleted:
-- Church AI products:
1. Look up premium_churches by stripe_subscription_id
2. SET premium_churches.status = "cancelled"
3. SET churches.is_premium = false
4. Sync to MailerLite "cwa-cancelled" group (non-blocking)
-- SermonWise Pro (if metadata.product = "sermon_pro"):
5. SET profiles.subscription_tier = "free"
-- ShareWiseAI (if metadata.product = "social"):
6. SET social_subscriptions.tier = "free", status = "cancelled"
7. Clear stripe_subscription_id
Data retention: No data is deleted on cancellation. The premium_churches record, all voice call logs, prayer requests, and chatbot conversations are preserved. The church just loses active features.
PewSearch handler
WHEN subscription is deleted:
1. Look up premium_churches by stripe_subscription_id (also loads church slug)
2. SET premium_churches: status = "cancelled", plan = "free"
3. Clear: website_template, chatbot_enabled, vanity_slug
4. SET churches.is_premium = false
5. Revalidate Next.js cache for /churches/[slug] and /claim/[slug]
IllustrateTheWord handler
WHEN subscription is deleted:
1. Look up user_subscriptions by stripe_customer_id
2. Call upsertSubscription() with status = "canceled"
Event: invoice.payment_failed
Fires when a recurring payment fails (card declined, expired, etc.).
ChurchWiseAI handler
WHEN payment fails:
1. IF no subscriptionId on the invoice: SKIP (one-time payment)
2. Look up premium_churches by subscriptionId or customerId
3. SET status = "past_due"
4. Create a Stripe Billing Portal session (so admin can update payment method)
5. Send payment failure email with:
- Church name
- Link to Billing Portal (or fallback to /contact page)
PewSearch handler
WHEN payment fails:
1. Extract subscriptionId from invoice.parent.subscription_details
2. IF no subscriptionId: SKIP
3. Look up premium_churches by stripe_subscription_id
4. SET status = "past_due"
5. Send payment failure email to admin_email with admin_token link
IllustrateTheWord handler
WHEN payment fails:
1. Extract subscriptionId from invoice.parent.subscription_details
2. Look up user_subscriptions by stripe_subscription_id
3. SET status = "past_due"
4. Look up customer email from Stripe
5. Send payment failure notification email
Event: customer.subscription.trial_will_end
Fires 3 days before a free trial ends (configurable in Stripe).
ChurchWiseAI handler
WHEN trial is ending:
1. Look up premium_churches by stripe_subscription_id
2. IF admin_email and admin_token exist:
- Look up church name
- Send trial ending email with admin dashboard link
PewSearch handler
WHEN trial is ending:
1. Look up premium_churches by customer or subscription ID
2. Calculate days remaining and formatted end date
3. IF admin_email exists:
- Send trial reminder email with church name, plan label, days remaining, admin token
IllustrateTheWord handler
WHEN trial is ending:
1. Get customer email from Stripe API
2. Send trial reminder email with days remaining and formatted end date
activateChurch deep dive (CWA)
This function runs on checkout.session.completed and handles all provisioning:
FUNCTION activateChurch(churchId, stripeCustomerId, stripeSubscriptionId, tier):
1. Check if church already has an active subscription
2. Normalize tier to plan + channel via normalizePlanKey()
Example: "bundle" -> plan: "pro", channel: "both"
3. IDEMPOTENCY: If existing subscription ID matches, SKIP (duplicate webhook event)
4. UPDATE premium_churches:
- status = "active"
- plan, channel from normalized tier
- stripe_customer_id, stripe_subscription_id
- activated_at (only for new activations, not upgrades)
5. SET churches.is_premium = true
6. AUTO-PROVISION CHATBOT (if not already provisioned):
- Check if organization_settings already exists for this church
- IF not: call provisionChatbot(churchId, premiumId, plan)
- IF provisioning fails: send setup alert email to founder (non-fatal)
7. CREATE ADMIN TEAM MEMBER (if none exists):
- Insert into church_team_members with role = "admin"
- Uses admin_name and admin_email from premium record
8. CREATE VOICE AGENT STUB (if plan includes voice):
- IF channel = "voice" OR "both":
- Check if church_voice_agents row already exists
- IF not: create stub with default settings
(prayer_requests ON, visitor_intake ON, callback_scheduling ON, status "pending_setup")
- Auto-populate notification_email from admin_email
- Send voice setup alert email to founder (Twilio number needs provisioning)
9. SEND WELCOME EMAIL (skip for upgrades -- they already have dashboard access):
- Get admin_email from premium record, or fall back to Stripe customer email
- If no email on record, retrieve from Stripe API and save it back
- Send welcome email with magic link (admin_token)
- RETRY up to 3 times with 2-second delay between attempts
- If all 3 fail: LOG as CRITICAL (admin can recover via /api/onboard/resend-link)
activateChurch deep dive (PewSearch)
FUNCTION activateChurch(churchId, stripeCustomerId, stripeSubscriptionId, tier):
1. IDEMPOTENCY: Check if already active with same subscription ID -- skip if so
2. UPDATE premium_churches:
- status = "active", plan = tier, activated_at, stripe IDs
- IF tier = "pro_website": also set website_template = "protestant_modern", chatbot_enabled = true
3. SET churches.is_premium = true, get church name and slug
4. REVALIDATE Next.js cache for /churches/[slug] and /claim/[slug]
Also revalidate /s/[vanity_slug] if a vanity slug exists
5. SEND WELCOME EMAIL:
- Same email-finding logic as CWA (admin_email, fall back to Stripe)
- Retry up to 3 times with 2-second delay
Error handling
All three handlers follow the same error handling pattern:
TRY processing the webhook event
CATCH any error:
LOG the error
CWA: also call reportError() to create an ops_errors record
RETURN 200 (to prevent Stripe from retrying -- the error is logged for investigation)
Returning 200 on errors is intentional. Stripe retries 4xx/5xx responses, which would cause the same error repeatedly. All handlers log errors for manual investigation instead.
Key design decisions
- CWA skips
customer.subscription.createdto avoid duplicate welcome emails. PewSearch handles it as a backup. - MailerLite syncing is always fire-and-forget -- subscription activation is never blocked by email marketing failures.
- Welcome emails retry 3 times because the magic link is the admin's only way to access the dashboard.
- PewSearch filters out CWA tiers to avoid double-processing when both webhooks receive the same event.
- ITW uses a separate
user_subscriptionstable (notpremium_churches) because it uses Supabase Auth instead of token-based auth.