Tech Blog•
October 14, 2026
•
8 min read
•
0 views

Payment Integration: What Founders Get Wrong

karmakoders Team
Design & Engineering
Diagram of a payment architecture with hosted checkout, a verified webhook receiver, an idempotent event processor, an order state machine and a ledger

Executive TL;DR

  • The browser is not a witness. A user closing the tab, a flaky network or a mobile app backgrounding itself all break redirect-based confirmation — so treat the signed webhook as the only source of truth and let the redirect be a nice-to-have.
  • Idempotency is not optional. Every charge request carries a key, every webhook handler is safe to run twice, and every order transition is conditional on current state. Without this, a retry is a second charge and a duplicate webhook is a second shipment.
  • Store money as integer minor units plus a currency code, never as a float, and keep your own ledger of what you believe happened. Reconcile it against the provider's settlement report on a schedule — the gateway dashboard is not your accounting system.

The first payment integration is almost always successful. The customer pays, the redirect to the success page appears, and the order appears. The problems come in patterns a few weeks after the support tickets: a customer charged twice, an order marked paid that was never captured, a refund showing in the gateway but not in the product, and a month-end total that doesn’t match the bank.

All those traces are to the same mental model. Code treats it like any other function. Call it. Get a result. Move on. But a payment is a conversation between a card network, an issuing bank, a provider, and sometimes a 3-D Secure challenge in a diffIt can take seconds or minutes, succeed after your request times out, or be interrupted if the user closes the tab. middle of it.

Which means the thing you built your success logic around—the redirect back to your site—is the least reliable signal in the system. It disappears when the user closes the tab, loses signal in a lift, or pays via a UPI or wallet app that never comes back to your page. Meanwhile, the provider told you the truth reliably via webhook, and nobody was listening.

So the good question, before selecting a provider or comparing fees, is how much of this machinery you want to own.

Hosted checkout, embedded fields, direct API, or an orchestration layer — how deep should your first integration go?

Hosted checkoutEmbedded fields / SDKDirect server-side APIOrchestration layer
Time to launchDays — a redirect, a webhook handler and a ledgerOne to two weeks — fields, states, 3-D Secure handling in your UILongest, and most of the time goes on compliance rather than codeWeeks, and only sensible once you already run one provider well
PCI scopeSmallest — card data never touches your servers or your DOMStill small if the fields are provider-hosted iframes; verify which SAQ applies to your exact integrationLargest — accepting raw card data pulls your systems into full PCI assessmentDepends on each connected provider; the orchestrator does not remove your obligations
Checkout UX controlLimited — provider's page, your logo and coloursHigh — your layout, your validation, your error copyTotal, including every edge case you must now designHigh, but every provider behaves slightly differently underneath
Engineering and ops burdenLow — the provider maintains the hard partsModerate — you own client state, retries and challenge flowsHigh and permanent — vaulting, network rules, authentication regimesHighest — routing logic, per-provider quirks, reconciliation across several settlement files
Lock-inModerate — the checkout page is theirs, but your ledger is yoursModerate, with front-end work to redo on a switchHigh — you have built around one provider's primitivesLowest in theory; that is the whole pitch
Local methods and auth supportBest — UPI, wallets, bank redirects and 3-D Secure handled for youGood, with more cases you must handle in your own UIYou implement and certify each one yourselfGood, and the main reason to adopt it — different providers for different markets
Fails whenCheckout conversion genuinely depends on an in-page flowYou assume the SDK's client callback means the money arrivedYou are not a payments company and did not need to beAdopted before you have volume, giving you two integrations to debug instead of one

When to pick each option

Try hosted checkout first. For a first integration, it is almost always correct. Card data never reaches your systems, so your PCI scope stays at its smallest, and the provider maintains 3-D Secure, local methods, retries, and the dozens of bank-specific behaviors you have never heard of. You spend your week building the things that are really yours—the webhook handler, the order state machine, the ledger—not re-creating a card form. The only legitimate reason to go beyond it is evidence, not esthetics: a measured drop-off at the redirect.

Embedded fields for when checkout is core to the experience—marketplaces, subscriptions with upsells, anything where leaving your page costs you conversions. The fields themselves are still hosted by the provider, so your compliance position is unchanged, but you are now responsible for client-side state and every failure mode in your own UI: a declined card, an abandoned 3-D Secure challenge, and a network drop mid-confirmation. Intentionally build those states, and still treat the client callback as a hint, not a confirmation.

Direct server-facing API route only if you have a very specific and unavoidable reason (unusual flow, platform model the SDKs don’t cover, provider with no hosted option in your market). Accepting raw card details raises your PCI obligations significantly and permanently; you assume authentication regimes that change by regulation. Most teams that take this path are addressing a product problem with a compliance project.

Build orchestration layer when you have real volume and a concrete reason: a second market that requires a local provider, need to failover when one gateway degrades, or acquirer pricing leverage. Before that, there are two integrations to maintain for the benefit of a routing diagram nobody needs yet.

Recommended architecture: the webhook is the truth, the ledger is yours

Rendering diagram…

// Three properties make this safe: the signature is verified, the event is
// deduplicated by the provider's ID, and the state transition is conditional.
// Skip any one of them and you will ship money bugs.

export async function handleWebhook(req: Request, res: Response) {
  // 1. VERIFY FIRST. An unverified endpoint is a public API for marking orders
  //    paid. Use the raw body — parsed JSON will fail signature checks.
  let event;
  try {
    event = provider.verifyWebhook(req.rawBody, req.header('signature'), SECRET);
  } catch {
    return res.status(400).end();           // never 200 an unverified event
  }

  // 2. DEDUPLICATE, then acknowledge fast. Providers retry on timeouts, so slow
  //    processing inside the request is how you get the same event five times.
  const isNew = await events.insertIfAbsent(event.id, event);   // unique on event.id
  res.status(200).end();
  if (!isNew) return;

  // 3. TRANSITION CONDITIONALLY. Webhooks can arrive out of order — a refund
  //    before its capture is normal. The guard, not the handler order, protects you.
  await db.tx(async (tx) => {
    const order = await tx.orders.lockById(event.data.orderId);

    const next = transition(order.status, event.type);          // returns null if invalid
    if (!next) return;                                          // already paid, or impossible — ignore

    await tx.orders.update(order.id, { status: next });

    // Money moves in the ledger, in integer minor units. Never a float:
    // 0.1 + 0.2 !== 0.3, and that error compounds across a million orders.
    await tx.ledger.record({
      orderId: order.id,
      amountMinor: event.data.amountMinor,                      // 123450
      currency: event.data.currency,                            // 'INR'
      kind: event.type,                                         // capture | refund | chargeback
      providerEventId: event.id,                                // the audit trail
    });

    // Fulfilment is a consequence of state, never of a redirect or a client callback.
    if (next === 'captured') await tx.outbox.enqueue('fulfil_order', { orderId: order.id });
  });
}

// The reconciliation job is the other half: each day, compare ledger captures
// against the settlement report. An unexplained difference is a bug you want to
// find in a daily alert, not in a quarterly audit.

MVP development planner

Scope, budget, and launch plan

Choose the product, platforms, and modules. The range, phases, and team update as you go. It is a planning figure, not a quote.

Product
Platforms
Modules
Design
Pace
Launch scale
Compliance

Newsletter

One architecture teardown or evaluation, every other week

Practical write-ups about framework choices, migrations, and scaling decisions, written for people who have to defend the call in a meeting.

No spam. Unsubscribe anytime.

Newsletter

Get the 2026 Tech Stack Guide

Join the KarmaKoders newsletter for architecture notes, stack evaluations, and build playbooks.

No spam. Unsubscribe anytime.

Book a call WhatsApp