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.