Back to blogs
Fintech / Payments
7 min read
Feb 22, 2025

Chargebacks and Webhook Updates for Single and Multi-Payment Flows

How Trulipay reconciles payment state across gateways using webhooks, handles the complexity of chargebacks in multi-payment order flows, and keeps the ledger accurate when disputes reverse funds that were already paid out.

ChargebacksWebhooksFintechPayment ReconciliationStripeNode.jsState Management
Table of contents

A chargeback in a single-payment flow is annoying but manageable. A chargeback in a multi-payment flow—where a customer paid for a single order across three installments, or where multiple items in one cart were charged to different payment methods—is a reconciliation nightmare.

This is how we handled both cases at Trulipay.

The State Machine Problem

Payment state transitions are driven by webhooks, not by our API calls. When we call stripe.paymentIntents.confirm(), the PaymentIntent doesn't immediately become succeeded—Stripe processes it asynchronously and sends us a webhook when it's done.

This means our payment state machine is event-driven:

type PaymentStatus =
  | "pending"
  | "processing"
  | "completed"
  | "failed"
  | "refunded"
  | "disputed"
  | "dispute_won"
  | "dispute_lost"
  | "chargeback_reversed";
 
const VALID_TRANSITIONS: Record<PaymentStatus, PaymentStatus[]> = {
  pending: ["processing", "failed"],
  processing: ["completed", "failed"],
  completed: ["refunded", "disputed"],
  failed: [],
  refunded: [],
  disputed: ["dispute_won", "dispute_lost"],
  dispute_won: [],
  dispute_lost: ["chargeback_reversed"],
  chargeback_reversed: [],
};
 
function canTransition(from: PaymentStatus, to: PaymentStatus): boolean {
  return VALID_TRANSITIONS[from]?.includes(to) ?? false;
}

Every webhook event maps to a state transition. If we receive a webhook for a transition that isn't valid (e.g., completed → completed), we ignore it—duplicate webhook delivery is normal; we must be idempotent.

Webhook Processing

Webhooks arrive out of order. A payment.refunded webhook can arrive before the payment.completed webhook for the same payment if there's a gateway-side processing delay. We handle this with an event store and state reconstruction:

async function processWebhook(event: WebhookEvent, gateway: string) {
  const existing = await getWebhookEvent(event.type, event.externalId);
  if (existing) return; // deduplication
 
  // Store the raw event before processing
  await storeWebhookEvent(event, gateway);
 
  const payment = await getPaymentByExternalId(event.externalId, gateway);
  if (!payment) {
    await scheduleRetry("process_webhook", event, { delay: 5000 });
    return; // payment hasn't been created yet; retry after 5s
  }
 
  if (!canTransition(payment.status, mapWebhookToStatus(event.type))) {
    return; // invalid transition; already in a terminal state or ahead of this event
  }
 
  await applyWebhookEvent(payment, event);
}

The retry for missing payments is important. In rare cases, Stripe sends a webhook for a payment that our system hasn't created yet—this happens when the webhook arrives faster than our own API response processing completes.

Single-Payment Chargeback Flow

When a customer files a chargeback on a single-payment order:

  1. Webhook arrives: charge.dispute.created from Stripe
  2. Funds held: Stripe deducts the disputed amount from the merchant's balance immediately
  3. State update: payment moves to disputed, order moves to under_review
  4. Merchant notified: with deadline and pre-gathered evidence
  5. Evidence submission: merchant reviews and submits counterevidence
  6. Resolution: Stripe returns charge.dispute.closed with outcome
async function handleDisputeResolution(event: WebhookEvent) {
  const dispute = event.raw as Stripe.Dispute;
  const payment = await getPaymentByExternalId(dispute.payment_intent as string, "stripe");
 
  if (dispute.status === "won") {
    // Funds returned to merchant
    await db.transaction(async (trx) => {
      await updatePaymentStatus(payment.id, "dispute_won", trx);
      await creditMerchantLedger(payment.merchant_id, payment.amount, "dispute_won", trx);
    });
    await notifyMerchant(payment.merchant_id, "dispute_won", { amount: payment.amount });
  } else if (dispute.status === "lost") {
    // Merchant permanently loses the funds
    await updatePaymentStatus(payment.id, "dispute_lost");
    await notifyMerchant(payment.merchant_id, "dispute_lost", {
      amount: payment.amount,
      chargeback_fee: dispute.balance_transactions[0]?.fee ?? 1500,
    });
  }
}

Multi-Payment Order Chargebacks

The complex case is a multi-payment order. Trulipay supports orders where the customer pays in installments, or where different items in a cart are charged through different payment methods (common when using buy-now-pay-later for some items and a card for others).

If a chargeback fires on one payment in a multi-payment order, we need to:

  1. Identify which payment in the order was disputed
  2. Determine if the chargeback affects deliverable items
  3. Decide whether to hold the entire order or just the disputed portion
async function handleMultiPaymentDispute(
  payment: Payment,
  disputeEvent: WebhookEvent
) {
  const order = await getOrderByPaymentId(payment.id);
  const allPayments = await getOrderPayments(order.id);
 
  // Calculate what percentage of the order this payment covers
  const disputedPaymentShare = payment.amount / order.total_amount;
 
  // If the disputed payment covers >50% of the order, hold everything
  if (disputedPaymentShare > 0.5) {
    await holdEntireOrder(order.id, "payment_disputed");
    await notifyMerchant(payment.merchant_id, "order_held_for_dispute", {
      order_id: order.id,
      payment_id: payment.id,
      dispute_share: disputedPaymentShare,
    });
    return;
  }
 
  // Otherwise, hold just the items covered by the disputed payment
  const disputedItems = await mapPaymentToOrderItems(payment.id, order.id);
  await holdOrderItems(order.id, disputedItems.map((i) => i.id));
 
  await notifyMerchant(payment.merchant_id, "order_partial_hold", {
    order_id: order.id,
    held_item_ids: disputedItems.map((i) => i.id),
  });
}

Ledger Accuracy After Chargeback

The hardest part of chargeback handling is keeping the ledger accurate when a dispute reverses funds that were already settled.

When a payment is completed, we credit the merchant's ledger. When the dispute is lost, we need to:

  1. Debit the merchant ledger for the original amount
  2. Debit an additional chargeback fee (typically $15-25 in the US)
  3. Update the running balance atomically
async function recordChargebackLoss(
  merchantId: string,
  payment: Payment,
  chargebackFee: number
) {
  await db.transaction(async (trx) => {
    // Reverse the original credit
    await trx.query(
      `INSERT INTO ledger_entries
       (merchant_id, payment_id, amount, type, description, created_at)
       VALUES ($1, $2, $3, 'chargeback_debit', 'Chargeback: original payment reversed', NOW())`,
      [merchantId, payment.id, -payment.net_amount]
    );
 
    // Record the chargeback fee
    await trx.query(
      `INSERT INTO ledger_entries
       (merchant_id, payment_id, amount, type, description, created_at)
       VALUES ($1, $2, $3, 'chargeback_fee', 'Chargeback processing fee', NOW())`,
      [merchantId, payment.id, -chargebackFee]
    );
 
    // Update running balance
    await trx.query(
      `UPDATE merchant_balances
       SET available_balance = available_balance - $1,
           total_chargeback_losses = total_chargeback_losses + $2
       WHERE merchant_id = $3`,
      [payment.net_amount + chargebackFee, payment.amount, merchantId]
    );
  });
}

The entire ledger update is in one transaction—a partial update would corrupt the merchant's balance.

Webhook Replay for Missing Events

In the rare case that a webhook was missed entirely (our server was down, a network blip occurred), we built a reconciliation endpoint that replays webhook events from Stripe:

async function reconcileStripeEvents(merchantId: string, since: Date) {
  const events = await stripe.events.list({
    created: { gte: Math.floor(since.getTime() / 1000) },
    type: "charge.dispute.*",
    limit: 100,
  });
 
  for (const event of events.data) {
    await processWebhookIdempotently(event);
  }
}

We run this nightly for all merchants to catch any events that slipped through.

Key Takeaways

  • Define valid state transitions explicitly—an invalid transition silently ignored is better than one that corrupts state.
  • Always store raw webhook payloads before processing—they're your source of truth for debugging and replay.
  • Retry processing for payments not yet created—webhooks can arrive faster than your own DB writes.
  • Multi-payment chargeback triage by payment share—decide whether to hold the whole order or just the affected items based on what percentage of order value is in dispute.
  • Ledger updates after chargeback must be atomic—partial balance updates leave you with irreconcilable books.