Payment Integration
Accept payments via Stripe/RevenueCat with idempotency, webhook verification, and reconciliation.
Why
Payment bugs charge real money to real people, and a double-charge is a support ticket plus a chargeback plus a lost customer. Idempotency keys prevent double-charges when a network retry sends the same request twice. Webhook verification prevents a fake 'payment succeeded' from an attacker granting a subscription for free. Reconciliation (comparing your DB to the provider's dashboard daily) catches silent drift before it becomes a billing dispute. Every one of these is a one-time setup that prevents a class of incident that costs 10-100x more to remediate than to prevent.
When
When the app collects money. Takes 4-8 h for a correct Stripe integration. Never skip — half a payment integration is worse than none.
How
1. Use the provider's SDK, never raw API calls — the SDK handles signing, retries, and versioning - Stripe: pnpm add stripe; initialize with the secret key from env - Always set an idempotency key on charge creation (use your order ID) 2. Create a checkout session server-side, redirect the client to it - Never build your own card form — PCI scope explodes from SAQ-A to SAQ-D - Use Stripe Checkout or Payment Element; the card number never touches your server 3. Set up webhooks for payment_intent.succeeded, invoice.paid, customer.subscription.deleted - Verify the signature with stripe.webhooks.constructEvent(rawBody, sig, secret) - Use the raw body, not JSON-parsed — signature verification needs the exact bytes 4. Make webhook handlers idempotent: check if the event ID was already processed - Store processed event IDs in a DB table; skip if already seen - This handles Stripe retrying the webhook (which it will, on any 5xx) 5. Run a daily reconciliation job: list charges from Stripe, compare to your orders table - Alert on any mismatch (paid in Stripe but not in DB, or vice versa)
Example
// Create checkout with idempotency
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function createCheckout(orderId: string, amount: number) {
return stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price_data: { currency: "usd", unit_amount: amount, product_data: { name: "Order " + orderId } }, quantity: 1 }],
success_url: `${env.URL}/success?o=${orderId}`,
cancel_url: `${env.URL}/cancel`,
}, { idempotencyKey: `checkout_${orderId}` });
}
// Webhook handler (verify signature, idempotent)
export async function handleWebhook(rawBody: Buffer, sig: string) {
const event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!);
if (await isProcessed(event.id)) return; // idempotent
if (event.type === "payment_intent.succeeded") await fulfillOrder(event.data.object);
await markProcessed(event.id);
}Common Problems
- ⚠JSON-parsing the webhook body before signature verification, so the signature check fails on the modified bytes.
- ⚠Not using idempotency keys, so a retry after a network blip charges the customer twice.
- ⚠Fulfilling the order in the checkout redirect instead of the webhook — if the user closes the tab, the order is never fulfilled.
- ⚠Storing card numbers to 'make repeat checkout easier' — this is a PCI-DSS violation that can result in fines.
Best Practices
- ✓Use Stripe Test Mode + test cards (4242...) in CI, and run a full purchase e2e on every PR that touches billing.
- ✓Log every state transition (pending → succeeded → fulfilled) with the Stripe event ID for audit traceability.
- ✓Set up Stripe's customer portal so users can self-serve cancellations — this cuts support tickets 60%.
- ✓Monitor the webhook endpoint's 5xx rate; Stripe disables webhooks that fail >80% over 72h, silently breaking billing.