Home
Backend from First Principles / Module 37 — Payments & Financial Systems

Payments & Financial Systems

Stripe-style integrations done safely. Webhooks, idempotency, reconciliation — and never storing card numbers.


The Cardinal Rules

Money is the area of backend engineering where bugs cost real dollars and trust. Five rules apply universally:

1. Never store card numbers. Ever. Use a payment provider (Stripe, Adyen, Braintree) that handles card data; you store only tokens or IDs they give you. Storing card data triggers PCI DSS compliance requirements that you almost certainly don't want to take on.

2. Every operation must be idempotent. Double-charging a customer is the most common — and most damaging — payment bug. Module 31's idempotency key pattern is non-negotiable for payment endpoints.

3. Webhooks are the source of truth, NOT the redirect URL. After a payment, the user's browser redirects back to your site. That redirect can fail (closed tab, network drop). The webhook from the payment provider, sent server-to-server, is reliable. Fulfill orders on the webhook, not the redirect.

4. Money math uses integers (Module 32). Never floats. Cents, not dollars. Always paired with currency.

5. Every transaction is logged for audit. Financial systems are audited. You need a permanent, immutable record of who paid what, when, with what method, and what happened next.


PCI DSS in One Section

PCI DSS (Payment Card Industry Data Security Standard) is the regulation that governs handling card data. It has 12 requirements covering everything from network security to access logs. Compliance is expensive — typically requiring annual audits if you're at scale.

The way to avoid most of it: don't touch card data.

How modern integrations do this:

Text
   Browser                                  Your Server
      │                                          │
      │ User enters card number                  │
      │ in Stripe Elements (an iframe            │
      │ hosted by Stripe, not your domain)       │
      │                                          │
      │ ──── direct to Stripe ───►   Stripe      │
      │ ◄─── token: tok_xyz ───      stores      │
      │                              card data   │
      │                                          │
      │ ──── token only ─────────►   Your server
      │                              receives    │
      │                              tok_xyz     │
      │                                          │
      │                              Server ──► Stripe
      │                              "charge $50 with tok_xyz"
      │                                          │
      │                                       Stripe charges,
      │                                       returns charge ID

The card number goes from the user's browser to Stripe's servers directly. Your server never sees it. Your server only handles tokens — opaque IDs that reference card data Stripe holds.

This approach ("SAQ A") is the simplest PCI tier and the only one most developers should engage with. It restricts your compliance scope dramatically.

Don't try to build "smarter" by accepting card numbers on your own form. The savings (slightly nicer UX) aren't worth the compliance cost.


Stripe-Style Integration Flow

Most modern payment integrations follow the same pattern. Stripe popularized it; everyone else (Square, Razorpay, Paddle) follows similar shapes.

Text
1. CREATE INTENT
   Browser                Your Server                Stripe
      │ "I want to pay" ──────►                       │
      │                       │ "Create payment intent
      │                       │  for $50 USD,
      │                       │  customer: cus_abc"
      │                       │   ────────────────► │
      │                       │   ◄──── client_secret
      │                       │
      │   ◄──── client_secret

2. CONFIRM PAYMENT (in browser, via Stripe.js)
      │ Stripe Elements collects card
      │ → POST direct to Stripe with client_secret
      │ → Stripe authenticates with bank (3DS if needed)
      │ → Stripe responds: success/failure
      │
      │ Browser redirects to your success page

3. WEBHOOK (server-to-server, the source of truth)
                              Stripe ────────────►   Your server
                              "payment_intent.succeeded
                               with payment_intent ID
                               pi_xyz, amount, customer"
                              
                              Your server:
                                ✓ verify webhook signature
                                ✓ check idempotency
                                ✓ fulfill order
                                ✓ store transaction record
                                ✓ return 200

A complete server-side webhook handler:

JavaScript
import express from 'express';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

// raw body needed for signature verification
app.post('/webhooks/stripe', express.raw({type: 'application/json'}), async (req, res) => {
  const sig = req.headers['stripe-signature'];

  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Idempotency: have we processed this event before?
  const seen = await db.query(
    'INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING event_id',
    [event.id]
  );
  if (seen.rowCount === 0) {
    return res.status(200).json({ already_processed: true });
  }

  switch (event.type) {
    case 'payment_intent.succeeded':
      await fulfillOrder(event.data.object);
      break;
    case 'payment_intent.payment_failed':
      await handleFailure(event.data.object);
      break;
    case 'charge.refunded':
      await handleRefund(event.data.object);
      break;
    // Unhandled events — return 200 anyway, don't block retries
  }

  res.status(200).json({ received: true });
});

Critical bits:
• Verify the webhook signature. Without this, anyone can POST fake events to your endpoint and trigger order fulfillment for free.
• Use raw body, not parsed JSON. Stripe signs the raw bytes; if your framework parses and re-stringifies, the signature won't match.
• Handle idempotency. Stripe retries failed webhooks for up to 3 days. You'll receive the same event multiple times.
• Always return 200 — even for events you don't care about. A 4xx/5xx tells Stripe to retry. Only return non-200 if you genuinely want a retry.


Reconciliation — Trusting But Verifying

Even with webhooks and idempotency, things drift. Webhook fails, network hiccups, your server is down for an hour. Your local view of payments and the payment provider's view diverge.

The fix is reconciliation: a periodic job that compares your records against the provider's records and fixes any mismatches.

Text
Daily reconciliation job:
  ├─► Fetch all payments from Stripe in last 24h
  ├─► Fetch all your local payment records in last 24h
  ├─► Diff them:
  │     • In Stripe but not local — webhook missed; replay
  │     • In local but not Stripe — your DB has a bogus record; investigate
  │     • Status mismatch — Stripe says succeeded, local says pending; fix
  ├─► Generate a report
  └─► Alert on any unresolved discrepancies

This is unglamorous work that no engineer is excited to build. It's also the difference between catching a $50 issue and discovering at month-end that you've over-counted revenue by $50,000.

Tooling: Stripe gives you Sigma queries to fetch this; alternatively, Stripe's Reports/Balance reports export to CSV daily. Most production teams build a daily reconciliation that pulls these and writes diffs to a dashboard.


Subscriptions, Refunds & Disputes

Beyond one-off payments, real systems handle ongoing relationships.

Subscriptions
The provider (Stripe Billing, Recurly, Chargebee) manages the recurring billing. Your job is to listen to webhooks for state changes and update entitlements:

Critical subtlety: subscription state can change on its own (a card expires mid-cycle). Always derive entitlements from the subscription's current status, not from "did they pay last month".

Refunds
Refunds are a separate API call to the provider. Your code must:
1. Allow the refund (full or partial)
2. Listen for the charge.refunded webhook
3. Update local records
4. Trigger any downstream actions (revoke product access, notify accounting)

Don't refund from your local database alone — the provider's API is the source of truth.

Disputes (chargebacks)
A chargeback is when the cardholder calls their bank and disputes the charge. Banks usually side with the customer. You receive a charge.dispute.created webhook.

What happens:
• The disputed amount is held back from your balance
• You have a window (usually 7-21 days) to submit evidence
• If you win, the funds return; if you lose, they go to the customer
• Each chargeback usually carries a fee from your provider

Disputes are a fact of life — typical e-commerce sees 0.5–1% dispute rates. Your job is to log the dispute, gather evidence (delivery proof, IP logs, signed agreements), and submit it through the provider's API. Repeated disputes can put you on the card networks' "high-risk" list, raising your processing fees.

The mental model: payments aren't a transaction at a moment in time. They're an ongoing relationship with the provider, the bank, and the customer that can change state for months after the original charge.


⁂ Back to all modules