Webhook Handling
Webhooks are the primary channel that turns money into orders and credits. This page explains how the events of all three providers converge on one fulfillment path.
Three layers
app/api/{stripe,creem,paypal}/webhook/route.ts
│ Does exactly three things: verify → switch/dispatch → pick the HTTP status by error kind
│ Holds no business logic
▼
lib/billing/{stripe,creem,paypal}.ts
│ Adapters: status normalization, subject resolution, payload → core inputs
│ SDK types stop here and never leak further
▼
lib/billing/fulfillment.ts
The provider-agnostic core; one transaction writes order + subscription + creditsAdding a provider means adding one route and one adapter. The core does not change.
Signature verification
| Provider | Mechanism | Environment variable |
|---|---|---|
| Stripe | stripe.webhooks.constructEvent (official SDK) | STRIPE_WEBHOOK_SECRET |
| Creem | verifyWebhookSignature (HMAC-SHA256, accepting both the three Standard Webhooks headers and the legacy single header) | CREEM_WEBHOOK_SECRET |
| PayPal | verifyPayPalWebhookSignature (calls PayPal's verification API with exponential-backoff retries) | PAYPAL_WEBHOOK_ID |
Note for local development
PayPal cannot deliver a verifiable real event to localhost, so signature verification is skipped when
NODE_ENV=development, with a warning in the logs. Stripe and Creem are not skipped — forward them with a CLI or tunnel locally.
What the fulfillment core offers
All three adapters eventually call these functions in lib/billing/fulfillment.ts:
| Function | What it does |
|---|---|
fulfillOneTimePurchase | Credit pack purchase: write the order and grant purchased credits, in one transaction |
recordPendingOneTimePurchase | Pending payment: write a pending order row with zero credits |
fulfillSubscriptionPayment | Subscription initial/renewal: sync the subscription row, write the order idempotently, reset subscription credits, initialize the yearly drip |
fulfillSubscriptionUpgrade | Mid-cycle plan change: grant the incremental difference — see Subscription Changes |
syncSubscription | Status sync only; never touches credits or the drip counters |
handleSubscriptionEnded | Subscription termination: clear the subscription credit bucket |
applyRefund | Refund: accumulate the refunded total, rewrite the original order's status, claw back credits proportionally |
Where idempotency comes from
Not from checks in the code, but from database constraints:
- A unique index on
orders(provider, provider_order_id), with writes going throughonConflictDoNothing - Refunds accumulate
amount_refundedinstead of appending refund rows credit_transactions.refunded_transaction_idis UNIQUE, so one spend can be refunded at most once
A replayed webhook, a repeated success-page trigger and a cron replay are therefore all safe by construction.
Subscription status normalization
Each provider's native statuses are translated inside its adapter into one canonical set, which is the only thing stored in the database:
active | trialing | past_due | unpaid | canceled | incomplete | expired | pausedcanceled and expired are terminal: once a provider subscription id reaches a terminal state it never comes back to life (re-subscribing means a new subscription id and a new row). syncSubscription uses this to reject "resurrection" rewrites caused by out-of-order webhooks. paused is not terminal, because a Stripe paused subscription can return to active.
Stripe events
Endpoint: POST /api/stripe/webhook
| Event | Handling |
|---|---|
checkout.session.completed | fulfillCheckoutSession — credit pack fulfillment; in subscription mode mostly a backfill |
invoice.paid | fulfillInvoicePaid — subscription initial, renewal, and billing_reason=subscription_update proration invoices |
customer.subscription.created / .updated | syncStripeSubscription — status sync only |
customer.subscription.deleted | handleSubscriptionDeleted — clear the subscription credit bucket |
charge.refunded | applyRefundForCharge — refund handling |
invoice.payment_failed | handleInvoicePaymentFailed — notify the customer that renewal failed |
radar.early_fraud_warning.created | handleEarlyFraudWarning — see below |
Unsubscribed event types are acked silently.
Radar early fraud warnings
The response behavior is configured with STRIPE_RADAR_EARLY_FRAUD_WARNING_TYPE:
| Value | Behavior |
|---|---|
refund,email | Automatically refund and email the admin |
refund | Automatically refund only |
email | Notify the admin only, no refund |
| Empty | Do nothing |
Creem events
Endpoint: POST /api/creem/webhook
| Event | Handling |
|---|---|
checkout.completed | fulfillCreemCheckout — one-time purchase fulfillment |
subscription.paid | fulfillCreemSubscriptionPaid — subscription initial/renewal |
subscription.active / .update / .trialing / .paused / .scheduled_cancel / .past_due / .unpaid | syncCreemSubscription — status sync |
subscription.canceled / .expired | handleCreemSubscriptionEnded — clear the subscription credit bucket |
refund.created | applyCreemRefund |
dispute.created | handleCreemDispute |
The Creem route has one extra design decision: only subscribed event types reach the SDK's strict typed parse. Unsubscribed types are acked right after signature verification, without parsing. That way a new Creem field or schema change on an event we do not care about cannot take the whole endpoint down.
A strict-parse failure on a subscribed type, by contrast, is a genuine "our code is stale" error: it returns 5xx into Creem's 24-hour retry window and raises an alert.
PayPal events
Endpoint: POST /api/paypal/webhook
One-time purchases (capture resource)
| Event | Handling |
|---|---|
PAYMENT.CAPTURE.COMPLETED | handlePayPalCaptureCompleted — fulfill; an existing pending row is promoted to completed in place and the credits are granted |
PAYMENT.CAPTURE.PENDING | recordPendingPayPalCapture — write a pending order row |
PAYMENT.CAPTURE.DENIED / .DECLINED | handlePayPalCaptureDenied — mark it failed |
PAYMENT.CAPTURE.REFUNDED / .REVERSED | handlePayPalCaptureRefunded |
The /api/paypal/capture-order route is the synchronous source of truth, and the webhook is the idempotent fallback — when that route is down, in-flight money still leaves a pending order row to trace.
Subscriptions
| Event | Handling |
|---|---|
PAYMENT.SALE.COMPLETED | handlePayPalSaleCompleted — subscription payment fulfillment (PayPal subscriptions use the v1 sale resource) |
PAYMENT.SALE.REFUNDED / .REVERSED | handlePayPalSaleRefunded |
BILLING.SUBSCRIPTION.ACTIVATED / .UPDATED | handlePayPalSubscriptionActivated |
BILLING.SUBSCRIPTION.SUSPENDED | handlePayPalSubscriptionSuspended |
BILLING.SUBSCRIPTION.PAYMENT.FAILED | handlePayPalSubscriptionPaymentFailed |
BILLING.SUBSCRIPTION.CANCELLED / .EXPIRED | handlePayPalSubscriptionEnded |
The error-handling contract
All three routes share one strategy:
Fulfillment throws
│
├─ Is it a PermanentFulfillmentError?
│ Retrying can never succeed (e.g. the buyer deleted their account and there is no organization subject)
│ → alert (Redis-deduplicated), then return 200 and ack — never enter the retry loop
│
└─ Any other error
Possibly transient, possibly our bug (a retry after the fix is deployed will catch up)
→ return 5xx so the provider retries with exponential backoffOn top of that, a failure on a money event (Stripe's checkout.session.completed / invoice.paid, Creem's checkout.completed / subscription.paid, and PayPal's equivalents) additionally triggers notifyCreditGrantFailed and emails the admin — because a paying customer is waiting.
That alert is deduplicated in Redis per object id: retries of the same payment and cron replays only log, instead of flooding the inbox.
The four notifications in lib/billing/notify.ts:
| Function | Scenario |
|---|---|
notifyCreditGrantFailed | Fulfillment failed; alert the admin |
notifyInvoicePaymentFailed | A renewal charge failed; ask the customer to update their card |
notifyFraudWarningAdmin | Fraud warning; alert the admin |
notifyFraudRefundUser | Automatically refunded due to fraud; notify the customer |
Settling duplicate subscriptions
When a customer gets around the frontend guard and ends up with two concurrent subscriptions (by paying a 24-hour-old session, say), lib/billing/duplicate.ts cleans up:
- The fulfillment core decides who "fulfilled first and wins" inside the transaction lock
- The loser's subscription row is marked canceled inside the lock, and the winner's reference is returned to the adapter
- The adapter performs the provider-side cancel, the automatic refund and the alert (the core never calls a provider API back)
- A failed refund throws, so the provider retries
The alert is Redis-deduplicated as well.
Recovering lost webhooks
lib/billing/reconcile.ts is driven every minute by the /api/cron/credits cron:
reconcileOverdueRenewals— replays the latest invoice per provider for overdue unrenewed subscriptions (at most 20 per tick)reconcilePendingPayPalCaptures— advances pending captures to success or failure (at most 20 per tick)
Cron guarantees delivery, not occurrence: credits are granted only once the money actually settles. See Scheduled Tasks for setup.
Testing locally
Stripe
stripe listen --forward-to localhost:3000/api/stripe/webhook
# Put the printed whsec_xxx into STRIPE_WEBHOOK_SECRET
# Fire a specific event
stripe trigger checkout.session.completedCreem / PayPal
Both need a publicly reachable URL. Forward with a tunnel such as ngrok locally, then point the webhook address in each dashboard at it. PayPal skips signature verification when NODE_ENV=development, which makes it easy to POST a hand-built event with any HTTP client.
Verification checklist
After a test payment, confirm these four things:
- A new row in
orderswith the rightstatusand the expectedcredits_granted - For subscriptions, the right status in
subscriptions, and for yearly plansnext_grant_at/remaining_grantsinitialized - A matching row in
credit_transactionswith coherent snapshot values - Replaying the same event changes nothing a second time