Common Payment Integration Bugs
The essential playbook for implementing common payment integration bugs in your SaaS.
Use this page to debug the most common payment integration failures in MVP and small SaaS deployments. Focus on request validation, webhook handling, idempotency, customer-to-user mapping, and environment separation before changing product logic.
Quick Fix / Quick Setup
# 1) Verify environment values
printenv | grep -E 'STRIPE|WEBHOOK|APP_ENV'
# 2) Confirm you are using matching test or live keys
# Expected pairs:
# STRIPE_SECRET_KEY=sk_test_... with webhook events from test mode
# STRIPE_SECRET_KEY=sk_live_... with webhook events from live mode
# 3) Replay events locally or on server
stripe listen --forward-to http://localhost:8000/webhooks/stripe
stripe events resend evt_xxx --webhook-endpoint=we_xxx
# 4) Check your endpoint returns 2xx quickly
curl -i -X POST https://yourapp.com/webhooks/stripe
# 5) Inspect recent logs for signature, customer, and subscription mapping errors
journalctl -u gunicorn -n 200 --no-pager
# action items:
# - validate webhook signature with the correct signing secret
# - store processed event IDs to prevent duplicate processing
# - map stripe_customer_id and stripe_subscription_id to your user/account
# - update access only from webhook events, not frontend success pages
# - keep test and live credentials fully separateMost payment bugs come from webhook misconfiguration, test/live mode mismatch, or updating billing state from the client instead of provider events.
What’s happening
- The checkout or subscription API call may succeed, but your app state stays wrong because webhook processing failed.
- A payment can complete in Stripe while your database still shows free plan if you rely on redirect success pages only.
- Duplicate webhook deliveries can create duplicate records or repeated entitlement changes if you do not use idempotency.
- A customer can pay successfully but not get access if you cannot map the provider customer/subscription back to your internal user or tenant.
- Test mode and live mode objects are separate. A valid event in one mode is invisible to credentials from the other mode.
Step-by-step implementation
1) Verify runtime environment
Check the actual values on the running server.
printenv | grep -E 'STRIPE|WEBHOOK|APP_ENV|DOMAIN'
python -c "import os; print(os.getenv('STRIPE_SECRET_KEY')); print(os.getenv('STRIPE_WEBHOOK_SECRET'))"Confirm all of these are set correctly:
STRIPE_SECRET_KEYSTRIPE_PUBLISHABLE_KEYSTRIPE_WEBHOOK_SECRETSTRIPE_PRICE_ID_*APP_URLSUCCESS_URLCANCEL_URL
Do not assume your local .env matches production.
2) Confirm test/live mode alignment
Common mismatches:
sk_test_...used with live webhook events- Live price ID used with test key
- Test customer referenced in live environment
Expected rule:
- Test secret key only works with test mode products, customers, subscriptions, and webhook events.
- Live secret key only works with live mode objects.
3) Validate checkout session creation
Log enough data when creating a checkout or subscription session.
Minimum fields to log:
- internal
user_idoraccount_id - selected
price_id stripe_customer_id- returned
checkout_session_id - request ID
- environment mode
Example server-side payload:
checkout_session = stripe.checkout.Session.create(
mode="subscription",
customer=user.stripe_customer_id,
line_items=[{"price": settings.STRIPE_PRICE_ID_PRO, "quantity": 1}],
success_url=f"{settings.APP_URL}/billing/success?session_id={{CHECKOUT_SESSION_ID}}",
cancel_url=f"{settings.APP_URL}/billing/cancel",
metadata={
"user_id": str(user.id),
"account_id": str(account.id),
},
)If session creation fails with 400 or 401, check:
- wrong secret key
- bad price ID
- malformed URL
- account/user auth failure before Stripe call
4) Persist customer mapping
Do not create a new provider customer on every checkout.
Store a stable mapping:
user_idoraccount_idstripe_customer_idstripe_subscription_id
Example schema:
ALTER TABLE accounts
ADD COLUMN stripe_customer_id TEXT UNIQUE,
ADD COLUMN stripe_subscription_id TEXT UNIQUE,
ADD COLUMN billing_status TEXT,
ADD COLUMN billing_plan TEXT;Safe rule:
- One internal account or tenant should map to one Stripe customer unless you intentionally support more than one.
5) Verify webhook signature with raw body
Signature verification fails when the request body is changed before verification.
Example in Python/Flask:
import stripe
from flask import request, abort
@app.post("/webhooks/stripe")
def stripe_webhook():
payload = request.get_data(as_text=False)
sig_header = request.headers.get("Stripe-Signature")
secret = os.environ["STRIPE_WEBHOOK_SECRET"]
try:
event = stripe.Webhook.construct_event(payload, sig_header, secret)
except stripe.error.SignatureVerificationError:
abort(400)
except ValueError:
abort(400)
# process event
return "", 200Check that your webhook route is not blocked by:
- auth middleware
- CSRF protection
- body-parsing middleware that rewrites payload
- reverse proxy path rewrite issues
For webhook handling details, see Handling Webhooks Correctly.
6) Return 2xx quickly and process safely
Webhook endpoints should acknowledge fast.
Bad pattern:
- verify
- run heavy DB sync
- send emails
- generate invoices
- call multiple third-party APIs
- then return response
Better pattern:
- verify signature
- store event
- enqueue processing
- return
200
If you use a worker, verify it is actually running:
docker compose logs -f worker
ps aux | grep -E 'celery|rq|gunicorn'7) Add idempotency for duplicate event delivery
Providers retry events on:
- timeout
- non-2xx response
- manual replay
- transient server error
Store processed event IDs with uniqueness protection.
Example table:
CREATE TABLE stripe_events (
id SERIAL PRIMARY KEY,
event_id TEXT NOT NULL UNIQUE,
event_type TEXT NOT NULL,
processed_at TIMESTAMP NOT NULL DEFAULT NOW()
);Example processing guard:
def mark_event_processed(event_id, event_type, db):
db.execute(
"""
INSERT INTO stripe_events (event_id, event_type)
VALUES (%s, %s)
ON CONFLICT (event_id) DO NOTHING
""",
(event_id, event_type),
)If the insert does nothing, skip the duplicate.
8) Update access from webhook events only
Do not treat the frontend success page as the source of truth.
Good source of truth:
checkout.session.completedinvoice.paidcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_failed
Common safe model:
- checkout starts a pending billing flow
- webhook confirms payment or subscription status
- database updates canonical billing state
- app reads entitlements from that billing state
If you need a full state model, see Managing Subscription States.
9) Handle the right events
At minimum, process:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.paidinvoice.payment_failed
Recommended logic:
checkout.session.completed
-> verify customer/account mapping exists
-> store session reference if useful
-> do not assume final entitlement without checking subscription/invoice context
invoice.paid
-> mark billing active if payment is successful
-> update renewal dates and entitlements
invoice.payment_failed
-> mark account past_due/unpaid according to your access rules
customer.subscription.updated
-> sync subscription status, period end, cancel flags, plan
customer.subscription.deleted
-> revoke or downgrade access10) Inspect provider-to-user mapping
A common root cause is successful payment with failed internal mapping.
Verify:
- account has
stripe_customer_id - active subscription has
stripe_subscription_id - webhook event customer matches stored customer
- metadata contains enough internal context where needed
Do not rely only on email address.
11) Check reverse proxy and route behavior
Inspect app and proxy logs:
journalctl -u gunicorn -n 200 --no-pager
journalctl -u nginx -n 200 --no-pager
grep -R "webhook" /etc/nginx /etc/systemd/system .Look for:
- wrong route path
- request blocked by auth
- body size issues
- HTTPS termination problems
- 301/302 redirects on webhook endpoint
A webhook endpoint should not redirect.
12) Test with CLI replay tools
Use the provider CLI to reproduce events exactly.
stripe listen --forward-to http://localhost:8000/webhooks/stripe
stripe trigger checkout.session.completed
stripe events resend evt_xxx --webhook-endpoint=we_xxxUse staging before production changes.
Process Flow
Common causes
- Test keys used with live webhook events or live price IDs
- Wrong webhook signing secret for the current endpoint
- Request body parsed before signature verification
- Webhook route blocked by auth middleware, CSRF checks, or reverse proxy config
- Price ID deleted, inactive, or created in the wrong mode
- Customer record not persisted, causing duplicate customers and broken mapping
- Subscription status stored locally but never reconciled from webhook events
- No idempotency handling for duplicate webhook deliveries
- Frontend redirect used as source of truth for payment success
- Queue worker offline, so webhook side effects never complete
- Database transaction rollback after provider API success
- Wrong success/cancel URLs or domain mismatch behind proxy/HTTPS
Debugging tips
Use these commands directly:
printenv | grep -E 'STRIPE|WEBHOOK|APP_ENV|DOMAIN'
curl -i https://yourapp.com/webhooks/stripe
curl -i -X POST https://yourapp.com/webhooks/stripe
stripe listen --forward-to http://localhost:8000/webhooks/stripe
stripe trigger checkout.session.completed
stripe events resend evt_xxx --webhook-endpoint=we_xxx
journalctl -u gunicorn -n 200 --no-pager
journalctl -u nginx -n 200 --no-pager
docker compose logs -f web
docker compose logs -f worker
ps aux | grep -E 'celery|rq|gunicorn'
python -c "import os; print(os.getenv('STRIPE_SECRET_KEY')); print(os.getenv('STRIPE_WEBHOOK_SECRET'))"
grep -R "webhook" /etc/nginx /etc/systemd/system .Additional checks:
- Log the first 1000 characters of raw webhook payload only in development.
- Compare event IDs in the provider dashboard against your app logs.
- Check webhook response codes in the provider dashboard.
- Inspect database linkage between user/account, customer ID, and subscription ID.
- Add a manual billing sync action for recovery if webhooks fail temporarily.
- Verify queued jobs created by webhook processing are actually consumed.
troubleshooting flowchart from checkout failure vs webhook failure vs entitlement mismatch.
Checklist
- ✓ Server has correct test or live payment keys
- ✓ Webhook endpoint is public, reachable, and returns 2xx fast
- ✓ Webhook signing secret matches the exact endpoint configuration
- ✓ Raw request body is used for signature verification
- ✓ Processed event IDs are stored with uniqueness protection
- ✓ Each user or tenant has a saved provider customer ID
- ✓ Each active paid account has a saved subscription ID when applicable
- ✓ Plan access updates only from trusted server events
- ✓ Price IDs are loaded from environment or config, not hardcoded ad hoc
- ✓ Success and cancel URLs use the correct production domain
- ✓ Logs include event type, event ID, customer ID, and outcome
- ✓ Queue worker is running if webhook processing is asynchronous
Related guides
- Stripe Subscription Setup (Step-by-Step)
- Handling Webhooks Correctly
- Managing Subscription States
- SaaS Production Checklist
FAQ
Should I grant paid access on the frontend success page?
No. Use the success page only for UX. Grant or revoke access from verified server-side webhook events or a provider API sync.
Why does signature verification fail even with the right secret?
The raw request body is likely being modified before verification, or you are using the signing secret from a different webhook endpoint or mode.
Why do duplicate webhook events happen?
Providers retry events on timeout or non-2xx responses. They may also redeliver during manual replay. Store event IDs and make handlers idempotent.
What subscription statuses should my app care about?
At minimum: trialing, active, past_due, unpaid, canceled, and incomplete. Map these to clear entitlement rules in your app.
Can I rely only on customer email to match billing records?
No. Email can change or collide across tenants. Store provider customer IDs and subscription IDs as the canonical mapping.
Final takeaway
Most payment integration bugs are not checkout bugs. They are state sync bugs.
Fix them in this order:
- verify environment and mode alignment
- verify raw-body webhook signature handling
- verify event idempotency
- verify customer/subscription mapping
- update entitlements from server-side billing events only
If billing looks correct in the provider dashboard but wrong in your app, start with webhooks and internal mapping before changing pricing or frontend logic.