Payments and webhooks

Trace Checkout, Stripe callbacks, transaction records, and entitlement grants to diagnose issues such as missing access after payment.

When troubleshooting payments, trace the Checkout ID, Stripe Event ID, and internal execution records for the same purchase. Do not make repeated real payments to reproduce an issue or assume that a payment success page means the account has received access.

1. Check the connection and plan

ConfigurationPurposeCommon error
payment.providerThe current payment providerConfiguration does not match the provider being called
STRIPE_CONNECTION_IDThe application's internal Stripe connection identifierChanging it after data exists prevents existing records from being found
STRIPE_SECRET_KEYCalls to the Stripe APITest/live mode or account mismatch
STRIPE_WEBHOOK_SECRETCallback signature verificationUsing a secret from a different endpoint or CLI listener
Product and plan keysLook up application plansThe key submitted by the frontend was renamed or does not exist
Plan priceIdCorresponds to a Stripe PriceA placeholder remains, or the Price belongs to another mode

STRIPE_CONNECTION_ID is your project's own stable identifier, not a Price ID supplied by Stripe. Configure it separately for test and production environments, and do not change it arbitrarily once production records exist.

npm run doctor and npm run doctor:remote can report missing variables, but do not verify which account or mode each Price belongs to or whether it is available for purchase. A plan's displayed price does not change the amount Stripe actually charges.

2. Checkout cannot be created

Inspect the Checkout creation request in the Network panel. First distinguish parameter validation, sign-in requirements, repeat-purchase restrictions, and Stripe API errors.

If the Price still contains _replace_me, create a price in the appropriate Stripe mode and enter its real ID. One-time and subscription plans also need Prices appropriate for their payment type.

For paymentsProductAlreadyPurchased, check allowRepurchase and the user's existing valid purchase records. Do not delete transaction history to let a user pay again. If your product needs to allow repeat purchases, change the plan rules and test how entitlements are handled after purchase.

The repeat-purchase check is not a concurrency lock. Multiple tabs may create multiple Checkout Sessions before the first payment completes. Keep only one active flow when testing.

3. Webhooks do not arrive locally

A local development URL cannot directly receive Stripe deliveries from the public internet. You can use the Stripe CLI to forward test events to the development server:

stripe login
stripe listen --forward-to http://127.0.0.1:5173/api/webhooks/stripe

Replace the port with the one actually used by npm run dev. Set STRIPE_WEBHOOK_SECRET in local .env to the signing secret printed by the listener, then restart the development server. This value is not interchangeable with the secret for an endpoint created in the dashboard. See Stripe local listening and webhooks for details.

Next, create a test Checkout from an application page and inspect CLI delivery, server logs, and the local database. A simulated payment event triggered on its own may lack an application-created Checkout, user association, or plan mapping. It cannot establish that the entire purchase flow is broken.

The project also provides webhook:stripe:dev and webhook:stripe:prod. They create remote webhook destinations and write the secret back to the corresponding environment file. They are not local forwarders. These scripts require a publicly accessible URL. Check existing destinations first instead of creating them repeatedly.

4. Callback signature, path, or configuration errors

The current endpoint is:

POST {VITE_SITE_URL}/api/webhooks/stripe
SymptomCheck in this order
404, 405Full URL, HTTP method, whether the target Worker is running the current version
Signature failureEndpoint or CLI secret, raw request body, Stripe-Signature header
PAYMENT_NOT_CONFIGUREDThe deployed Worker's connection ID, API key, and webhook secret
5xxFirst server exception, Stripe API, D1, and application callback processing
WEBHOOK_BUSYWhether the same event already has a processing lease, along with timestamps and related logs

Signature verification uses the raw request body. Do not parse JSON and then serialize it again, or disable signature verification for troubleshooting. See Stripe signature troubleshooting.

After editing local .env.production, run an update deployment. Saving the file does not update the Worker's runtime secrets. When changing domains, also check whether the Stripe destination still points to the old domain.

5. Use the event ledger to locate the processing stage

Run a read-only query for the target Stripe Event against the correct application database, DB. Replace the example identifier with the current event ID:

SELECT id, provider, connection_id, event_id, event_type,
       status, created_at, updated_at
FROM webhook_events
WHERE provider = 'stripe'
  AND event_id = 'evt_replace_me'
ORDER BY id DESC
LIMIT 10;

Also use connection_id to identify which connection the event belongs to. Do not export the full raw_body to shared logs. It contains application and user information.

ResultMeaning and next step
No recordCheck that delivery reached the correct environment, signature verification passed, and the event type is supported
processingInspect the lease, update time, and current execution logs. The status alone does not prove that processing is still active
failedInspect last_error in a controlled environment to identify the failed processing step
succeededContinue checking transactions and entitlements. This does not mean all subsequent asynchronous tasks are complete

The current webhook_events table has no attempt_count field. Check Stripe's delivery history for delivery attempts and command_execution for Command attempts. These are separate counts.

Unsupported events produce a payment.webhook.ignored log entry and return success without entering this ledger. Duplicate events that have already succeeded also return success immediately. A 2xx response alone therefore does not prove that a new transaction was created.

6. Payment succeeded, but roles or entitlements are missing

Find the last successful step in this sequence:

Stripe payment status
→ Delivery and signature verification of the corresponding webhook
→ checkout_sessions / transactions / transaction_items
→ Plan-to-Price mapping
→ event_execution / command_execution
→ user_role_capability / user_entitlement_capability

One-time payments and subscriptions use different business events. Do not expect every subscription to have a PaymentSucceededEvent. For subscriptions, also inspect subscriptions, subscription_items, and subscription creation, update, and end events.

Check for these conditions:

  • The callback's Price ID does not map to a current product plan, so the expected grant input cannot be generated.
  • The customized plan does not configure the roles or entitlements it needs to grant.
  • Payment records were updated, but Event creation or Command execution failed.
  • The entitlement was saved, but the page checks a different capability key, user, or source.
  • The user completed the Checkout page flow, but the payment method is still awaiting asynchronous confirmation.

See Background jobs and analytics for querying execution records and interpreting their statuses. Do not manually add transactions or grant account permissions before identifying the cause. Doing so can hide the failure and break the relationships needed for later revocation.

7. Duplicate delivery, busy states, and replay

Stripe may deliver events more than once, and delivery order should not be treated as the order in which business operations occurred. See Stripe webhook delivery behavior.

The template identifies an event by provider, connection ID, and Event ID. A successful record returns success immediately. An active processing lease produces a non-2xx response so the provider can retry later. The current Stripe processing lease has a maximum age of three minutes. After it expires, a subsequent delivery attempts to reacquire processing rights. No background timer guarantees recovery at the moment of expiration.

Before replaying an event, confirm that the configuration is fixed and check whether transactions, entitlements, or external side effects have already occurred. Idempotency logic skips events that have already succeeded. Replaying one will not automatically repair an independently failed asynchronous Command.

Do not delete the event ledger, reset its status to unprocessed, or fabricate a new event ID to force reprocessing. If processing results disagree with external state, reconcile the records first, then design a fix in the appropriate application layer.

8. Billing portal and refunds

The billing portal requires a payment_customers record for the user. The Customer must belong to the current connection and mode. Then check Stripe's portal configuration and return URL. Do not test with a Customer from another environment.

The current refund processing saves refund records, and the application callback logs refund.updated, but it does not automatically revoke roles or entitlements granted by a one-time purchase. Continued access after a successful refund does not necessarily indicate a callback failure. The product's post-refund rules may not yet be implemented. Check the corresponding subscription events separately for subscription termination.

Verify the fix

Complete a purchase from the application in test mode and confirm that transactions, events, access permissions, and page behavior agree. Then verify that duplicate deliveries do not create duplicate grants and that users who have not purchased still cannot access restricted features. Recover from production incidents by reconciling the original records. Do not require users to pay again to prove the fix works.