Payment System Checklist
The essential playbook for implementing payment system checklist in your SaaS.
Use this checklist to verify that your payment system is safe to launch and stable in production. It covers billing flows, webhook handling, subscription lifecycle states, customer access control, retries, observability, and rollback readiness. This page is for small SaaS teams using Stripe or a similar recurring billing provider.
Quick Fix / Quick Setup
Payment system production quick setup:
1. Use live API keys only in production.
2. Create separate test and live webhook endpoints.
3. Verify webhook signatures before processing events.
4. Store provider customer_id, subscription_id, price_id, and current billing status in your database.
5. Make webhook handlers idempotent using event IDs.
6. Gate paid features from your internal subscription state, not client-side checks.
7. Handle these states explicitly: trialing, active, past_due, unpaid, canceled, incomplete.
8. Configure retry-safe checkout success flows.
9. Test upgrade, downgrade, cancel, resume, failed payment, and webhook replay.
10. Add alerts for failed webhooks, failed invoices, and abnormal churn spikes.If you only do one thing before launch: make webhook processing idempotent, verify signatures, and base access control on your database subscription state instead of redirect success pages.
What’s happening
Most payment bugs are state-sync bugs, not charge bugs.
- The billing provider tracks invoices, subscriptions, retries, and payment attempts.
- Your app must convert that external billing state into internal feature access.
- Production failures usually come from missing webhook handling, duplicate event processing, mixed environments, or incomplete status mapping.
- The main risk is inconsistency:
- customer was charged but access was never granted
- customer canceled but access stayed active
- payment failed but nobody was alerted
- webhook was retried and your app applied the same state transition twice
Process Flow
Step-by-step implementation
1) Confirm account and environment setup
Verify the provider account before launch:
- live mode enabled
- business profile completed
- payout settings reviewed
- tax settings checked if needed
- support email and invoice branding configured
Keep test and live environments fully separate:
- API keys
- webhook secrets
- product IDs
- price IDs
- domains
- callback URLs
- database records
Do not reuse test IDs in production config.
Example environment layout:
# test
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_test_xxx
STRIPE_PRICE_STARTER=price_test_starter
# live
STRIPE_SECRET_KEY=sk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_live_xxx
STRIPE_PRICE_STARTER=price_live_starter2) Define your internal billing model
Your app needs its own billing state table. At minimum store:
create table billing_subscriptions (
id bigserial primary key,
account_id bigint not null unique,
provider text not null default 'stripe',
customer_id text not null,
subscription_id text unique,
price_id text,
status text not null,
current_period_end timestamptz,
cancel_at_period_end boolean not null default false,
trial_end timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);Also store processed webhook events:
create table billing_webhook_events (
event_id text primary key,
event_type text not null,
processed_at timestamptz not null default now()
);Recommended status model:
trialingactivepast_dueunpaidcanceledincomplete
Do not infer access from frontend state.
3) Create checkout sessions server-side only
Never trust price_id, plan, or amount from the client.
Example server-side checkout creation:
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [
{
price: process.env.STRIPE_PRICE_STARTER,
quantity: 1,
},
],
success_url: 'https://yourdomain.com/billing/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'https://yourdomain.com/billing/cancel',
metadata: {
account_id: String(accountId),
},
});Use a stable server-side mapping:
const PRICE_LOOKUP = {
starter: process.env.STRIPE_PRICE_STARTER,
pro: process.env.STRIPE_PRICE_PRO,
};4) Verify webhooks before processing
Webhook handlers must:
- use the raw request body
- verify the provider signature
- reject invalid events
- process each event once
Example Express handler:
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error('Invalid webhook signature', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
try {
const exists = await db.query(
'select 1 from billing_webhook_events where event_id = $1',
[event.id]
);
if (exists.rowCount > 0) {
return res.status(200).json({ received: true, duplicate: true });
}
await db.query('begin');
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(event.data.object);
break;
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
await syncSubscription(event.data.object);
break;
case 'invoice.paid':
await handleInvoicePaid(event.data.object);
break;
case 'invoice.payment_failed':
await handleInvoicePaymentFailed(event.data.object);
break;
default:
break;
}
await db.query(
'insert into billing_webhook_events (event_id, event_type) values ($1, $2)',
[event.id, event.type]
);
await db.query('commit');
return res.status(200).json({ received: true });
} catch (err) {
await db.query('rollback');
console.error('Webhook processing failed', event.id, err);
return res.status(500).json({ error: 'webhook_processing_failed' });
}
});Important:
- do not place JSON body parsing middleware before the webhook route if your framework requires raw body verification
- keep the endpoint public and unauthenticated
- return
2xxonly after safe handling
| Feature | Option A | Option B |
|---|---|---|
| — | — | — |
table mapping provider webhook events to internal database updates and feature-access actions.
5) Map provider state to app access
Your app should decide access from database state, not redirect success or dashboard guesses.
Example access rules:
| Billing status | Access |
|---|---|
| trialing | allow |
| active | allow |
| past_due | allow or grace period |
| unpaid | block based on policy |
| canceled | block at end of term or immediately |
| incomplete | block |
Example gate check:
function hasPaidAccess(subscription) {
if (!subscription) return false;
switch (subscription.status) {
case 'trialing':
case 'active':
return true;
case 'past_due':
return true; // if grace period policy
default:
return false;
}
}If billing is workspace-based, gate by account_id, not by user row.
6) Implement lifecycle flows
Test these flows before launch:
- new signup + checkout
- trial start
- renewal
- failed renewal
- upgrade
- downgrade
- immediate cancel
- cancel at period end
- resume
- webhook replay
- manual support correction
Document expected proration rules.
Example plan update:
await stripe.subscriptions.update(subscriptionId, {
items: [
{
id: subscriptionItemId,
price: process.env.STRIPE_PRICE_PRO,
},
],
proration_behavior: 'create_prorations',
});7) Build support and audit tooling
Minimum support view should show:
- account ID
- customer ID
- subscription ID
- current plan
- status
- current period end
- cancel at period end
- recent webhook event IDs
- last invoice status
This reduces time to resolve access complaints.
8) Add logging and monitoring
Log every billing transition with:
- timestamp
- account ID
- customer ID
- subscription ID
- old status
- new status
- source event ID
Monitor for:
- webhook delivery failures
- handler
4xx/5xx - invoice payment failures
- checkout session creation failures
- churn spikes
- local/provider state mismatches
If your webhook endpoint is behind Nginx, preserve request delivery and upstream errors.
Basic Nginx example:
location /webhooks/stripe {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}For deployment hardening, see Deploy SaaS with Nginx + Gunicorn.
9) Prepare recovery procedures
Document manual actions for:
- replaying missed webhooks
- restoring correct subscription state
- refund handling
- dispute handling
- accidental cancellation
- duplicate local records
- provider outage fallback
Back up billing tables before major billing changes.
10) Run end-to-end tests in both test and live mode
Before launch:
- complete a test-mode full lifecycle run
- complete a live-mode smoke test with a real payment method
- confirm webhooks hit the production endpoint
- verify access toggles correctly
Common causes
- Using the checkout success redirect as the only source of truth
- Not verifying webhook signatures
- Parsing the webhook body before signature verification
- Processing the same event multiple times
- Mixing test and live secrets or price IDs
- Not storing
customer_idorsubscription_id - Missing handling for
past_due,unpaid,incomplete, orcanceled - Gating access from client-side state
- Webhook route blocked by auth middleware or proxy config
- Local database drifting from provider state after failed webhook deliveries
- No alerts for failed invoices or webhook failures
- Assuming one user always equals one subscription
Debugging tips
Start with the provider dashboard event history, then compare against your logs and database.
Useful commands:
curl -i https://yourdomain.com/webhooks/stripe
stripe listen --forward-to localhost:8000/webhooks/stripe
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
stripe events list --limit 10
stripe subscriptions retrieve sub_xxx
stripe customers retrieve cus_xxx
grep -i "webhook\|stripe\|invoice\|subscription" /var/log/app.log
journalctl -u gunicorn -n 200 --no-pager
docker logs <app_container> --tail 200Check these in order:
- Is the live webhook URL correct and publicly reachable?
- Is signature verification using the correct secret?
- Is the route using raw request body?
- Are duplicate events being ignored safely?
- Does the database row contain the expected
customer_id,subscription_id,price_id, andstatus? - Is the feature gate reading fresh billing state from the database?
- Does provider subscription data match your local row?
For webhook transport and deploy issues, also review Deploy SaaS with Nginx + Gunicorn.
Checklist
- ✓ Live API keys configured only in production
- ✓ Separate test and live products and prices verified
- ✓ Separate test and live webhook secrets verified
- ✓ Webhook endpoint deployed over HTTPS and publicly reachable
- ✓ Webhook signatures verified with the correct secret
- ✓ Webhook route uses raw request body where required
- ✓ Webhook handlers are idempotent
- ✓ Processed event IDs stored
- ✓ Billing records store provider customer ID
- ✓ Billing records store provider subscription ID
- ✓ Access control derives from internal billing state
- ✓
trialing,active,past_due,unpaid,canceled, andincompletehandled explicitly - ✓ Upgrade flow tested
- ✓ Downgrade flow tested
- ✓ Cancel flow tested
- ✓ Resume flow tested
- ✓ Failed payment flow tested
- ✓ Checkout success flow does not grant access by itself
- ✓ Invoice emails and billing notifications configured
- ✓ Support/admin billing lookup exists
- ✓ Billing state transition logs retained with event IDs
- ✓ Alerts configured for webhook failures
- ✓ Alerts configured for invoice failures
- ✓ Manual recovery steps documented
- ✓ Test mode end-to-end run completed
- ✓ Live mode smoke test completed with a real payment method
- ✓ Refund, dispute, and chargeback handling documented if relevant
Related guides
- Stripe Subscription Setup (Step-by-Step) for base recurring billing implementation
- Handling Webhooks Correctly for event delivery hardening and signature verification
- Managing Subscription States for mapping provider states to app access
- Deploy SaaS with Nginx + Gunicorn for production webhook endpoint deployment
- SaaS Production Checklist for full launch validation
- Security Checklist for production security review
- Auth System Checklist for account and session validation
FAQ
What is the minimum safe payment setup for launch?
Server-side checkout creation, verified webhooks, idempotent event processing, stored customer/subscription IDs, and feature access based on database billing state.
Which events matter most for subscriptions?
At minimum:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.paidinvoice.payment_failed
Should I grant access immediately after checkout success?
No. Use webhook-confirmed state in your database as the source of truth.
What billing status should unlock paid features?
Usually active and trialing. Decide explicitly whether past_due gets grace-period access.
How do I avoid duplicate charges or duplicate state updates?
Use provider idempotency for API requests where supported, and store webhook event IDs to deduplicate event processing.
Can I use one webhook endpoint for test and live?
Avoid it. Separate endpoints and secrets reduce mistakes.
Should I block users immediately when payment fails?
Usually no. Apply a defined grace period, notify the customer, and revoke access only after your policy threshold is reached.
How do I verify billing state if users report access issues?
Compare the provider subscription object, recent event history, and your local billing record for the same customer and subscription IDs.
Do I need a separate checklist if I already tested checkout?
Yes. Checkout success does not validate renewals, payment failures, cancellations, resumes, webhook retries, or monitoring.
Final takeaway
A production-ready payment system depends on state correctness and operational visibility.
Treat the payment provider as the billing source and your database as the application access source, synchronized through verified, idempotent webhooks.
Before launch:
- test every lifecycle transition
- monitor webhook and invoice failures
- keep environments separate
- gate access from internal billing state only