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

bash
# 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 separate

Most 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.

bash
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_KEY
  • STRIPE_PUBLISHABLE_KEY
  • STRIPE_WEBHOOK_SECRET
  • STRIPE_PRICE_ID_*
  • APP_URL
  • SUCCESS_URL
  • CANCEL_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_id or account_id
  • selected price_id
  • stripe_customer_id
  • returned checkout_session_id
  • request ID
  • environment mode

Example server-side payload:

python
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_id or account_id
  • stripe_customer_id
  • stripe_subscription_id

Example schema:

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

python
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 "", 200

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

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

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

python
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.completed
  • invoice.paid
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.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.completed
  • customer.subscription.created
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.paid
  • invoice.payment_failed

Recommended logic:

text
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 access

10) 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:

bash
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.

bash
stripe listen --forward-to http://localhost:8000/webhooks/stripe
stripe trigger checkout.session.completed
stripe events resend evt_xxx --webhook-endpoint=we_xxx

Use staging before production changes.

frontend checkout
provider
webhook endpoint
billing sync
entitlement update

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:

bash
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.

What kind of webhook event problem are you seeing?
Checkout failure
Check Stripe session creation, client redirect, and network errors in browser console
Webhook failure
Check Stripe dashboard delivery logs, endpoint reachability, and signature verification
Entitlement mismatch
Check webhook processing logic, DB subscription state, and feature-gate middleware

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

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:

  1. verify environment and mode alignment
  2. verify raw-body webhook signature handling
  3. verify event idempotency
  4. verify customer/subscription mapping
  5. 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.