Skip to content
Back to Build Tutorials

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.