Skip to main content

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

HandlerURLProducts handled
ChurchWiseAIchurchwiseai.com/api/stripe/webhookVoice Agent, Chatbot tiers (Starter/Pro/Suite), Bundles, AI Starter Kit, SermonWise Pro, ShareWiseAI
PewSearchpewsearch.com/api/stripe/webhookPremium Page ($9.95), Pro Website ($19.95)
IllustrateTheWordillustratetheword.com/api/stripe/webhookITW Premium ($9.95/mo, $99.50/yr)

All three endpoints listen for the same five Stripe event types:

  1. checkout.session.completed
  2. customer.subscription.updated
  3. customer.subscription.deleted
  4. invoice.payment_failed
  5. customer.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

  1. CWA skips customer.subscription.created to avoid duplicate welcome emails. PewSearch handles it as a backup.
  2. MailerLite syncing is always fire-and-forget -- subscription activation is never blocked by email marketing failures.
  3. Welcome emails retry 3 times because the magic link is the admin's only way to access the dashboard.
  4. PewSearch filters out CWA tiers to avoid double-processing when both webhooks receive the same event.
  5. ITW uses a separate user_subscriptions table (not premium_churches) because it uses Supabase Auth instead of token-based auth.