Menu

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 + credits

Adding a provider means adding one route and one adapter. The core does not change.

Signature verification

ProviderMechanismEnvironment variable
Stripestripe.webhooks.constructEvent (official SDK)STRIPE_WEBHOOK_SECRET
CreemverifyWebhookSignature (HMAC-SHA256, accepting both the three Standard Webhooks headers and the legacy single header)CREEM_WEBHOOK_SECRET
PayPalverifyPayPalWebhookSignature (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:

FunctionWhat it does
fulfillOneTimePurchaseCredit pack purchase: write the order and grant purchased credits, in one transaction
recordPendingOneTimePurchasePending payment: write a pending order row with zero credits
fulfillSubscriptionPaymentSubscription initial/renewal: sync the subscription row, write the order idempotently, reset subscription credits, initialize the yearly drip
fulfillSubscriptionUpgradeMid-cycle plan change: grant the incremental difference — see Subscription Changes
syncSubscriptionStatus sync only; never touches credits or the drip counters
handleSubscriptionEndedSubscription termination: clear the subscription credit bucket
applyRefundRefund: 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 through onConflictDoNothing
  • Refunds accumulate amount_refunded instead of appending refund rows
  • credit_transactions.refunded_transaction_id is 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 | paused

canceled 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

EventHandling
checkout.session.completedfulfillCheckoutSession — credit pack fulfillment; in subscription mode mostly a backfill
invoice.paidfulfillInvoicePaid — subscription initial, renewal, and billing_reason=subscription_update proration invoices
customer.subscription.created / .updatedsyncStripeSubscription — status sync only
customer.subscription.deletedhandleSubscriptionDeleted — clear the subscription credit bucket
charge.refundedapplyRefundForCharge — refund handling
invoice.payment_failedhandleInvoicePaymentFailed — notify the customer that renewal failed
radar.early_fraud_warning.createdhandleEarlyFraudWarning — see below

Unsubscribed event types are acked silently.

Radar early fraud warnings

The response behavior is configured with STRIPE_RADAR_EARLY_FRAUD_WARNING_TYPE:

ValueBehavior
refund,emailAutomatically refund and email the admin
refundAutomatically refund only
emailNotify the admin only, no refund
EmptyDo nothing

Creem events

Endpoint: POST /api/creem/webhook

EventHandling
checkout.completedfulfillCreemCheckout — one-time purchase fulfillment
subscription.paidfulfillCreemSubscriptionPaid — subscription initial/renewal
subscription.active / .update / .trialing / .paused / .scheduled_cancel / .past_due / .unpaidsyncCreemSubscription — status sync
subscription.canceled / .expiredhandleCreemSubscriptionEnded — clear the subscription credit bucket
refund.createdapplyCreemRefund
dispute.createdhandleCreemDispute

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)

EventHandling
PAYMENT.CAPTURE.COMPLETEDhandlePayPalCaptureCompleted — fulfill; an existing pending row is promoted to completed in place and the credits are granted
PAYMENT.CAPTURE.PENDINGrecordPendingPayPalCapture — write a pending order row
PAYMENT.CAPTURE.DENIED / .DECLINEDhandlePayPalCaptureDenied — mark it failed
PAYMENT.CAPTURE.REFUNDED / .REVERSEDhandlePayPalCaptureRefunded

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

EventHandling
PAYMENT.SALE.COMPLETEDhandlePayPalSaleCompleted — subscription payment fulfillment (PayPal subscriptions use the v1 sale resource)
PAYMENT.SALE.REFUNDED / .REVERSEDhandlePayPalSaleRefunded
BILLING.SUBSCRIPTION.ACTIVATED / .UPDATEDhandlePayPalSubscriptionActivated
BILLING.SUBSCRIPTION.SUSPENDEDhandlePayPalSubscriptionSuspended
BILLING.SUBSCRIPTION.PAYMENT.FAILEDhandlePayPalSubscriptionPaymentFailed
BILLING.SUBSCRIPTION.CANCELLED / .EXPIREDhandlePayPalSubscriptionEnded

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 backoff

On 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:

FunctionScenario
notifyCreditGrantFailedFulfillment failed; alert the admin
notifyInvoicePaymentFailedA renewal charge failed; ask the customer to update their card
notifyFraudWarningAdminFraud warning; alert the admin
notifyFraudRefundUserAutomatically 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:

  1. The fulfillment core decides who "fulfilled first and wins" inside the transaction lock
  2. The loser's subscription row is marked canceled inside the lock, and the winner's reference is returned to the adapter
  3. The adapter performs the provider-side cancel, the automatic refund and the alert (the core never calls a provider API back)
  4. 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.completed

Creem / 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:

  1. A new row in orders with the right status and the expected credits_granted
  2. For subscriptions, the right status in subscriptions, and for yearly plans next_grant_at / remaining_grants initialized
  3. A matching row in credit_transactions with coherent snapshot values
  4. Replaying the same event changes nothing a second time