Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 081 / 100

Payment engineering roadmap: what developers should know

What to learn, in order.

Expona Admin9 min read

By day 81 of building payment systems, you've likely encountered edge cases that make you question every design decision. The gap between a hobby payment integration and a production-grade system handling millions is measured in scenarios you haven't coded yet. This article charts the payment engineering roadmap: what developers should know, laying out the conceptual and practical foundations in the order that matters most.

Payment flows and PaymentIntents

Before you can handle anything complex, you need to understand the core abstraction: the PaymentIntent. This is Stripe's state machine for a single payment attempt. It encapsulates authorization, capture, 3D Secure challenges, and retry logic into one object that persists across your application restarts, network failures, and customer refreshes.

A PaymentIntent flows through states: requires_payment_method → requires_confirmation → requires_action (for 3D Secure) → processing → succeeded (or canceled/requires_action). Each state tells you exactly what your frontend must do next. When you create a PaymentIntent, Stripe returns a client_secret: never log it, never store it in your database beyond the request lifetime, and always use it client-side to confirm the payment.

Consider an e-commerce checkout where a customer adds items and proceeds to payment. Your backend creates the PaymentIntent with the order amount, metadata linking to your order ID, and optional customer context. The frontend receives the client_secret, uses Stripe.js to mount a payment element or card input, and calls confirmPayment(). Stripe handles the complexity: if the card requires 3D Secure, the customer sees a challenge; if it needs additional verification, the intent stays in requires_action until resolved.

The critical insight: never assume a payment succeeded just because your customer closed the browser. The PaymentIntent lives server-side. Poll its status or rely on webhooks to confirm the outcome.

typescript
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

// Backend: create PaymentIntent
app.post('/create-payment-intent', async (req, res) => {
  const { orderId, amount } = req.body;
  const intent = await stripe.paymentIntents.create({
    amount,
    currency: 'usd',
    metadata: { orderId },
    automatic_payment_methods: {
      enabled: true,
    },
  });
  res.json({ clientSecret: intent.client_secret });
});

// Frontend: confirm payment
const { error, paymentIntent } = await stripe.confirmPayment({
  elements,
  clientSecret: clientSecret,
  redirect: 'if_required',
});

if (paymentIntent.status === 'succeeded') {
  // Mark order as paid
}

Webhooks, idempotency and state machines

Webhooks are how Stripe tells you what happened. Your backend polls nothing; instead, Stripe pushes events to an endpoint you control. You register for events like payment_intent.succeeded, charge.refunded, and customer.subscription.updated. The catch: webhooks can arrive out of order, arrive multiple times, or not arrive at all if your server crashes.

Idempotency is your defense. Stripe accepts an Idempotency-Key header on all mutation requests. If you retry a request with the same key within 24 hours, Stripe returns the cached result instead of processing it again. For webhook handlers, you implement idempotency on your side by logging the webhook event ID and checking whether you've already processed it.

Your order state machine should look like this:

State Trigger Next State
pending User submits payment awaiting_confirmation
awaiting_confirmation payment_intent.succeeded webhook paid
awaiting_confirmation payment_intent.payment_failed webhook failed
paid charge.refunded webhook refunded

Implement webhook handlers as pure functions that check whether the event ID exists in your database. If it does, return 200 immediately. If not, process the event and insert the ID atomically. This pattern handles duplicates and network retries gracefully.

typescript
// Webhook handler: check idempotency
app.post('/webhook', bodyParser.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

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

  // Idempotency check
  const processed = await db.webhookEvents.findOne({ stripeId: event.id });
  if (processed) {
    res.json({ received: true });
    return;
  }

  // Process event
  if (event.type === 'payment_intent.succeeded') {
    const { metadata } = event.data.object;
    await db.orders.update(
      { id: metadata.orderId },
      { status: 'paid' }
    );
  }

  // Record as processed
  await db.webhookEvents.insertOne({ stripeId: event.id, processedAt: new Date() });
  res.json({ received: true });
});

Subscriptions, refunds and disputes

Subscriptions add a layer of scheduling and recurring state. A Subscription contains one or more Prices (formerly Plans), each with billing intervals (monthly, yearly). Stripe automatically creates Invoices and Charges on the schedule you define. Your job: listen to invoice events, handle failures, and react to customer changes (upgrade, downgrade, cancel).

Refunds come in three flavors: full refund (customer gets 100% back), partial refund (you return part of the charge), and reversal (you reverse the charge and create a new one with the correct amount). Always refund through the Charge or PaymentIntent, not by manually transferring money. Stripe updates the transaction, reports it correctly for compliance, and sends the customer a receipt.

Disputes occur when a customer contests a charge with their card issuer. You receive a charge.dispute.created webhook. At this point, the money is withdrawn from your account and placed in dispute. You have 7 days to respond with evidence. Common evidence: order confirmation, shipping tracking, customer communication. Upload it via the Stripe Dashboard or API. If you lose the dispute, the chargeback fee applies on top of the refunded amount.

Real scenario: a SaaS company offers annual subscriptions. A customer signs up in January, gets charged $1,200. In March, they dispute it claiming unauthorized use. Your evidence: email confirmation with their digital signature, login records showing API calls from their IP, and invoice delivery confirmation. You upload this to Stripe before the deadline. If you win, you keep the money; if you lose, you lose $1,200 plus a $15 chargeback fee.

typescript
// Create subscription
const subscription = await stripe.subscriptions.create({
  customer: customerId,
  items: [{ price: priceId }],
  payment_behavior: 'default_incomplete',
  expand: ['latest_invoice.payment_intent'],
});

// Handle failed invoice
app.post('/webhook', async (req, res) => {
  const event = req.body;
  if (event.type === 'invoice.payment_failed') {
    const invoice = event.data.object;
    // Notify customer, attempt retry, or cancel subscription
    await stripe.subscriptions.update(invoice.subscription, {
      collection_method: 'send_invoice',
      days_until_due: 30,
    });
  }
  res.json({ received: true });
});

// Refund a charge
const refund = await stripe.refunds.create({
  charge: chargeId,
  metadata: { reason: 'customer_request' },
});

Ledgers, reconciliation and marketplaces

As your payment volume grows, you need a ledger: a double-entry accounting log of every transaction. Each deposit, fee, refund, and chargeback is a debit and credit. Your ledger lets you reconcile with Stripe's bank statements daily and catch discrepancies immediately.

Reconciliation is the process of matching your ledger against Stripe's. You pull Stripe's payout report (all charges, refunds, fees) for a given period, compare line-by-line against your database, and flag mismatches. Common issues: a webhook arrived late so you didn't record the charge, or a refund processed in Stripe but not in your system. Automate this daily; manual reconciliation at scale is error-prone.

Marketplaces add another dimension: you're holding money on behalf of merchants. A customer pays you $100 for a service; you take $10 as commission and owe the merchant $90. Your ledger must track this three-way relationship: customer debit, platform credit (commission), merchant credit (payout owed). Use Stripe Connect: the customer charges your Stripe account, and you create charges or transfers to merchant accounts.

In a ride-hailing app, a passenger books a ride for $50. Stripe charges the passenger; your platform takes $7.50 commission; the driver gets $42.50. Your ledger records three entries, and your nightly payout service sends $42.50 to the driver's connected account. If the driver disputes the ride, you handle the refund and adjust both the driver's payout and your commission accordingly.

typescript
// Ledger schema
interface LedgerEntry {
  id: string;
  transactionId: string; // Stripe charge ID, refund ID, payout ID
  account: 'platform' | 'merchant_' + merchantId;
  type: 'charge' | 'refund' | 'fee' | 'payout';
  amount: number;
  currency: string;
  timestamp: Date;
  metadata: Record;
}

// Record a charge with commission
async function recordCharge(chargeId, amount, merchantId, platformFee) {
  await db.ledger.insertMany([
    { transactionId: chargeId, account: 'platform', type: 'charge', amount, timestamp: new Date() },
    { transactionId: chargeId, account: 'platform', type: 'fee', amount: platformFee, timestamp: new Date() },
    { transactionId: chargeId, account: `merchant_${merchantId}`, type: 'charge', amount: amount - platformFee, timestamp: new Date() },
  ]);
}

// Daily reconciliation
async function reconcile(stripePayoutId) {
  const stripePayout = await stripe.payouts.retrieve(stripePayoutId);
  const stripeCharges = await stripe.charges.list({ payout: stripePayoutId });
  
  for (const charge of stripeCharges.data) {
    const local = await db.ledger.findOne({ transactionId: charge.id });
    if (!local) {
      console.error(`Missing charge in ledger: ${charge.id}`);
    }
  }
}

Common mistakes

  • Treating PaymentIntent creation as confirmation. A PaymentIntent existing is not the same as payment succeeding. Always wait for the webhook or poll the status before fulfilling the order.
  • Ignoring webhook delivery guarantees. Assuming webhooks always arrive once and in order leads to lost or duplicated transactions. Always implement idempotency checks.
  • Storing client_secret permanently. The client_secret is for frontend use only. Logging it or storing it in your database is a security risk. Stripe generates it per PaymentIntent for a reason.
  • Refunding without reason metadata. When you issue a refund, include a reason (customer request, duplicate charge, etc.). This helps you spot refund patterns and fight chargebacks later.
  • Skipping reconciliation until problems arise. Reconcile daily. A missing $100 transaction found after a month is much harder to trace than one found the next day.
  • Not handling subscription cancellation correctly. If a subscription invoice fails and you don't retry or cancel, Stripe keeps trying, creating duplicate invoice events and confusion in your ledger.

Key takeaways

  • PaymentIntents are state machines; always check their status server-side and never trust the frontend alone.
  • Implement idempotency on every webhook handler to survive duplicates and network failures.
  • Refunds and disputes are part of the payment lifecycle; handle them with evidence and clear ledger records.
  • Build a ledger from day one; it's your source of truth for reconciliation and marketplace accounting.
  • Automate reconciliation daily; manual spreadsheets don't scale and hide bugs.
  • Subscriptions require listening to multiple event types and handling invoice failures gracefully.
  • Test your webhook handlers offline and ensure they're truly idempotent before deploying to production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles