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

Payment engineering checklist: best practices explained

Before going live.

Expona Admin10 min read

Payment engineering checklist: best practices explained. Before going live.

Introduction

You've built the UI, integrated the SDK, and tested in the sandbox environment. Now comes the part that separates production-ready systems from ones that hemorrhage money and trust: a payment engineering checklist. Before going live, we need to verify that our payment infrastructure handles the messy reality of production—failed networks, concurrent requests, customer chargebacks, and the 2 AM alert on Sunday when reconciliation breaks.

This checklist covers four pillars that we've learned through operating payment systems at scale: idempotent webhooks, server-side price enforcement, graceful refund and dispute handling, and continuous monitoring with reconciliation. Each one sounds straightforward until you're debugging why a customer was charged twice or why your revenue report doesn't match your bank statement.

Webhooks verified and idempotent

Webhook handling is where most payment integrations fail in production. A webhook fires when Stripe notifies you of an event—a charge succeeded, a dispute opened, a refund completed. The issue: webhooks are not guaranteed to arrive exactly once. Network timeouts, server crashes, and retries mean you'll see the same webhook multiple times. Process it naively, and you'll refund a customer twice or record duplicate revenue.

Verification means two things. First, cryptographically confirm that the webhook came from Stripe and wasn't forged. Stripe signs every webhook with a secret key you can use to validate the signature.

typescript
import Stripe from 'stripe';

export async function handleWebhook(req: Request) {
  const signature = req.headers.get('stripe-signature');
  const body = await req.text();
  
  let event;
  try {
    event = Stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    console.error('Webhook signature verification failed:', err.message);
    return new Response('Webhook signature verification failed', { status: 400 });
  }
  
  // Process the verified event
  await processEvent(event);
  return new Response(JSON.stringify({ received: true }), { status: 200 });
}

Second, make your event processing idempotent. Store the event ID in your database. If you receive the same event ID again, skip processing—just return success. This way, a retry doesn't cause duplicate charges or refunds.

typescript
async function processEvent(event: Stripe.Event) {
  // Check if we've already processed this event
  const existing = await db.query(
    'SELECT id FROM webhook_events WHERE stripe_event_id = $1',
    [event.id]
  );
  
  if (existing.rows.length > 0) {
    console.log(`Event ${event.id} already processed, skipping`);
    return;
  }
  
  // Process the event (charge, refund, dispute, etc.)
  switch (event.type) {
    case 'charge.succeeded':
      await handleChargeSucceeded(event.data.object);
      break;
    case 'charge.refunded':
      await handleChargeRefunded(event.data.object);
      break;
    case 'charge.dispute.created':
      await handleDisputeCreated(event.data.object);
      break;
  }
  
  // Mark this event as processed
  await db.query(
    'INSERT INTO webhook_events (stripe_event_id, event_type, processed_at) VALUES ($1, $2, NOW())',
    [event.id, event.type]
  );
}

Store the webhook event ID and timestamp in your database. This becomes your audit trail. When something goes wrong—a customer reports a double charge, or finance asks why revenue spiked—you can trace exactly which webhooks were processed and when.

Server-side prices

Never trust the client. A customer's browser can be manipulated. If you calculate the price on the frontend and send it to your payment processor, an attacker can intercept it and lower the amount. This is not hypothetical—it happens constantly.

The rule: always calculate the price on your server, immediately before creating a charge or payment intent. Never accept a price from the client.

typescript
// Bad: client sends the price
async function createPaymentIntentBad(req: Request) {
  const { customerId, amount } = req.body; // amount comes from client!
  
  const intent = await stripe.paymentIntents.create({
    customer: customerId,
    amount: amount, // This is dangerous
    currency: 'usd'
  });
  
  return intent;
}

// Good: server calculates the price
async function createPaymentIntentGood(req: Request) {
  const { customerId, itemIds } = req.body; // only identifiers from client
  
  // Fetch items from database
  const items = await db.query(
    'SELECT id, price FROM items WHERE id = ANY($1)',
    [itemIds]
  );
  
  // Recalculate price server-side
  let total = 0;
  for (const item of items.rows) {
    total += item.price;
  }
  
  // Apply tax, shipping, or discounts
  const tax = calculateTax(total, customerAddress);
  const shipping = calculateShipping(items);
  const finalAmount = total + tax + shipping;
  
  const intent = await stripe.paymentIntents.create({
    customer: customerId,
    amount: Math.round(finalAmount * 100), // always in cents
    currency: 'usd'
  });
  
  return intent;
}

This extends to discounts and taxes. Look up any discount code in your database, recalculate tax based on the customer's address, apply shipping rules—all on the server. Your frontend displays the calculated total, but never uses it for the actual charge.

Store the price calculation details with the order or payment intent. Later, when auditing or investigating disputes, you can see exactly what was charged and why.

Refund and dispute handling

Refunds and disputes are the dark mirror of successful payments. A refund is when you voluntarily return money; a dispute is when a customer claims the charge was unauthorized or the product didn't match the description.

For refunds, your system needs to track refund status. A refund request can take days to process. Don't immediately mark a refund as complete in your system—instead, listen for the Stripe webhook that confirms it.

typescript
async function requestRefund(chargeId: string, reason: string) {
  // Insert a refund record in "pending" state
  const result = await db.query(
    `INSERT INTO refunds (charge_id, reason, status, requested_at)
     VALUES ($1, $2, 'pending', NOW())
     RETURNING id`,
    [chargeId, reason]
  );
  
  const refundId = result.rows[0].id;
  
  // Create the refund in Stripe
  try {
    const stripeRefund = await stripe.refunds.create({
      charge: chargeId,
      metadata: { internal_refund_id: refundId }
    });
    
    // Store the Stripe refund ID
    await db.query(
      'UPDATE refunds SET stripe_refund_id = $1 WHERE id = $2',
      [stripeRefund.id, refundId]
    );
  } catch (err) {
    // Mark as failed if Stripe rejects it
    await db.query(
      'UPDATE refunds SET status = $1, error = $2 WHERE id = $3',
      ['failed', err.message, refundId]
    );
    throw err;
  }
}

// In your webhook handler
async function handleChargeRefunded(charge: Stripe.Charge) {
  // Stripe confirms the refund succeeded
  await db.query(
    `UPDATE refunds SET status = 'completed', completed_at = NOW()
     WHERE stripe_refund_id = $1`,
    [charge.refunds.data[0].id]
  );
}

Disputes require a different approach. When a customer disputes a charge, Stripe notifies you via a webhook. Your system should flag the order, preserve evidence, and potentially halt shipment until the dispute is resolved.

typescript
async function handleDisputeCreated(dispute: Stripe.Dispute) {
  const chargeId = dispute.charge;
  
  // Find the order associated with this charge
  const order = await db.query(
    'SELECT id FROM orders WHERE stripe_charge_id = $1',
    [chargeId]
  );
  
  if (order.rows.length === 0) {
    console.error(`No order found for charge ${chargeId}`);
    return;
  }
  
  const orderId = order.rows[0].id;
  
  // Flag the order and prevent fulfillment
  await db.query(
    `UPDATE orders SET status = 'disputed', dispute_evidence_needed = true
     WHERE id = $1`,
    [orderId]
  );
  
  // Log the dispute for review
  await db.query(
    `INSERT INTO disputes (order_id, stripe_dispute_id, reason, status)
     VALUES ($1, $2, $3, 'needs_response')`,
    [orderId, dispute.id, dispute.reason]
  );
  
  // Alert operations team
  await notifyOps(`Dispute received for order ${orderId}: ${dispute.reason}`);
}

Monitoring and reconciliation

Production payment systems require continuous monitoring. You need alerts for anomalies: a spike in failed charges, webhook delays, or refund rates exceeding a threshold. More importantly, you need daily reconciliation—comparing your database records against Stripe's records to catch discrepancies before customers discover them.

Set up basic monitoring on key metrics. Export these to your observability platform (Datadog, New Relic, CloudWatch, etc.).

typescript
import { StatsD } from 'node-statsd';

const metrics = new StatsD();

async function recordPaymentMetrics(event: Stripe.Event) {
  const charge = event.data.object as Stripe.Charge;
  
  metrics.gauge('payment.amount', charge.amount);
  metrics.increment('payment.count');
  
  if (event.type === 'charge.succeeded') {
    metrics.increment('charge.succeeded');
  } else if (event.type === 'charge.failed') {
    metrics.increment('charge.failed');
    metrics.gauge('charge.failure_reason', 1, {
      reason: charge.failure_reason
    });
  }
  
  // Track refund rate
  if (charge.refunded) {
    metrics.increment('charge.refunded');
    metrics.gauge('refund.amount', charge.amount_refunded);
  }
}

For reconciliation, run a daily job that pulls charges from Stripe and compares them to your orders table. Look for missing orders, mismatched amounts, or charges with no corresponding record.

typescript
async function dailyReconciliation() {
  const yesterday = new Date();
  yesterday.setDate(yesterday.getDate() - 1);
  
  // Fetch all charges from Stripe for yesterday
  const charges = await stripe.charges.list({
    created: {
      gte: Math.floor(yesterday.getTime() / 1000),
      lt: Math.floor(new Date().getTime() / 1000)
    },
    limit: 100
  });
  
  for (const charge of charges.data) {
    // Find corresponding order
    const order = await db.query(
      'SELECT id, total_amount FROM orders WHERE stripe_charge_id = $1',
      [charge.id]
    );
    
    if (order.rows.length === 0) {
      // Charge exists in Stripe but not in our database
      console.error(`Orphaned charge in Stripe: ${charge.id}, amount: ${charge.amount}`);
      await alertFinance(`Missing order for charge ${charge.id}`);
      continue;
    }
    
    const orderAmount = Math.round(order.rows[0].total_amount * 100);
    if (orderAmount !== charge.amount) {
      console.error(
        `Amount mismatch for order ${order.rows[0].id}: ` +
        `expected ${orderAmount}, got ${charge.amount}`
      );
      await alertFinance(`Amount mismatch for order ${order.rows[0].id}`);
    }
  }
  
  console.log(`Reconciliation complete for ${yesterday.toISOString()}`);
}

// Run daily at 3 AM UTC
schedule.scheduleJob('0 3 * * *', dailyReconciliation);

Common mistakes

Processing webhooks without idempotency: Blindly updating order status or issuing refunds when a webhook arrives. Use event IDs to prevent duplicate processing.

Calculating prices on the client: Sending price calculations from the frontend to the payment processor. Always recalculate on the server.

Ignoring webhook delays: Assuming a webhook will arrive within seconds. Webhooks can be delayed or batched. Your system must handle eventual consistency.

No refund state tracking: Treating refunds as instantaneous. In reality, they take time. Store refund status and update it based on webhooks.

Skipping reconciliation: Assuming Stripe and your database are always in sync. They're not. Daily reconciliation catches bugs and fraudulent activity.

Insufficient logging: Not storing webhook payloads or charge details. When disputes arise, you need to audit what happened.

Key takeaways

  • Verify webhook signatures and process events idempotently using event IDs to prevent duplicate charges or refunds.
  • Always calculate prices server-side; never trust price values sent from the client.
  • Track refund and dispute status through webhooks; don't assume immediate completion.
  • Set up daily reconciliation to compare your database against Stripe's records and catch discrepancies early.
  • Monitor key metrics (charge success rate, refund rate, webhook lag) and alert on anomalies.
  • Store complete audit trails—event IDs, timestamps, calculation details—for investigation and compliance.
  • Test webhook handling in production with Stripe's event relay feature before going live.
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