Field Notes
Commerce & Integration
12 min· 19 August 2026

Payment Integration: The Redirect Is Not the Truth

By Ganesh

The bug report is always some version of the same thing. A customer says they paid. Your system says they didn't. The gateway dashboard agrees with the customer, and now someone in finance is manually marking an order as paid while the customer waits.

Nearly every payment integration defect traces back to one assumption made early and never revisited: that the customer landing on your success page is the same event as the money moving. It isn't. Those are two separate things that usually happen close together, and building as though they're one is what produces the whole family of problems below.

The short version

  • →The redirect is a user-experience event. The webhook is the financial one.
  • →Assume webhooks arrive late, out of order, and more than once. Design handlers accordingly.
  • →Send an idempotency key on every write. Retries are not an edge case.
  • →Store money as integers in minor units with an explicit currency, never as a float.
  • →Run a scheduled reconciliation job. Some payments will always slip through, and you want to find them before your customer does.

Why the redirect can't be trusted

Between authorising a payment and arriving back on your site, the customer's browser has to survive a round trip. Plenty of things interrupt that: they close the tab because the bank page said success and they consider themselves done, they're on mobile and the app switches away, the connection drops, or they hit back at exactly the wrong moment.

In every one of those cases, the payment succeeded and your success handler never ran. If that handler is what marks the order paid, you now have a customer holding a debit and an order sitting in pending.

The inverse is also true and worse. A redirect can be replayed, guessed or manipulated. If your success page marks an order paid because a query parameter said so, that is not a bug so much as an unlocked door.

A rule that removes a class of bugs

Order state changes on gateway-verified events only. The redirect shows the customer a status page and nothing more. If the webhook hasn't landed yet, show "confirming your payment" and poll — that's a few seconds of ambiguity in exchange for never getting the state wrong.

Webhooks are messier than the docs suggest

Three properties matter, and gateway documentation tends to mention them briefly enough that people skim past.

They repeat. If your endpoint is slow, errors, or the acknowledgement is lost in transit, the gateway retries. You will receive the same event twice, and a handler that isn't idempotent will happily issue a second refund.

They arrive out of order. A captured event can land before the authorized event that logically precedes it. Handlers that assume sequence will corrupt state in ways that are painful to reconstruct afterwards.

They sometimes don't arrive. Your endpoint was redeploying, or a DNS blip ate it, or retries exhausted while you were down. This is what reconciliation exists for.

csharp
[HttpPost("webhooks/payments")]
public async Task<IActionResult> Handle(CancellationToken ct)
{
    var raw = await new StreamReader(Request.Body).ReadToEndAsync(ct);

    // Verify against the RAW body. Deserialising first and re-serialising
    // changes the bytes and the signature will never match.
    if (!_verifier.IsValid(raw, Request.Headers["X-Signature"].ToString()))
    {
        _logger.LogWarning("Rejected webhook with invalid signature");
        return Unauthorized();
    }

    var evt = JsonSerializer.Deserialize<GatewayEvent>(raw)!;

    // At-least-once delivery: the gateway's event id is the dedupe key.
    if (!await _events.TryRecordAsync(evt.Id, ct))
    {
        _logger.LogInformation("Duplicate webhook {EventId} ignored", evt.Id);
        return Ok();   // 200, not an error — it's a normal occurrence
    }

    // Out-of-order delivery: refuse to move state backwards.
    await _payments.ApplyIfNewerAsync(evt, ct);

    // Acknowledge fast. Do the slow work — email, invoicing, fulfilment —
    // on a queue. A gateway that times out waiting will retry.
    await _queue.EnqueueAsync(new PaymentEventReceived(evt.Id), ct);

    return Ok();
}

The comment about the raw body is worth dwelling on. Signature verification runs over the exact bytes the gateway sent. Model binding that parses and re-serialises will reorder keys or change whitespace, at which point verification fails for reasons that look like a configuration problem and aren't.

Idempotency keys

Any request that moves money — charge, refund, capture — should carry an idempotency key derived from the business intent, not generated fresh per call. Then a retry after a timeout returns the original result rather than charging twice.

This matters because timeouts are ambiguous by nature. Your request timed out; you do not know whether the gateway processed it. Without an idempotency key you have two bad options: retry and risk a double charge, or don't and risk a missing payment. With one, you just retry.

csharp
// Deterministic per intent, so a retry reuses it. Do NOT use Guid.NewGuid()
// at the call site — that defeats the entire mechanism.
var idempotencyKey = $"order:{order.Id}:capture:{order.PaymentAttempt}";

Asynchronous methods break the mental model further

Card payments resolve in seconds, which lets teams get away with treating payment as synchronous. Bank transfers, mandates and UPI don't behave that way. A UPI payment can sit pending while the customer approves it in a different app, and settle minutes later — well after the customer has given up and closed your tab.

If your checkout waits synchronously for a result, that's a hang. If it assumes failure on timeout, you'll cancel orders that later succeed. The model that works is a pending state your UI can represent honestly, plus a status poll for customers still watching and a webhook for the majority who aren't.

Regulation around card storage and tokenization has also moved considerably in India over recent years, and the specifics differ by card network and gateway. Confirm the current position with your gateway rather than relying on any article — including this one — for that part.

Reconciliation is not optional

Regardless of how carefully the above is built, a residue of payments will end up in a state your database doesn't reflect. A nightly job that fetches the gateway's settled transactions for the period and compares them against your orders is what turns that from an unpleasant surprise into a routine report.

Two directions to check, and both matter. Payments the gateway has that you don't — a customer was charged and your system missed it, which is the urgent one. And orders you marked paid that the gateway has no record of, which usually means a bug in your own state transitions and is the more alarming of the two.

Make the mismatch visible

Put the reconciliation output somewhere a person actually looks — a channel, a dashboard, a daily email — rather than a log nobody opens. The value isn't the job running; it's someone noticing on Tuesday that yesterday's mismatch count went from two to forty.

Small things that cause disproportionate pain

Money as floating point. Store integers in the smallest unit with an explicit currency code. Floats accumulate rounding differences that surface during reconciliation, long after the code responsible has been forgotten.

Card data touching your servers. Use the gateway's hosted fields or redirect flow. The moment a card number passes through your infrastructure, your compliance obligations expand dramatically, and there is rarely a commercial reason to accept that.

Trusting test mode. Sandbox environments succeed far more readily than production. Deliberately exercise the failure paths — declines, timeouts, duplicate webhooks, out-of-order events — because those are the paths that will run at 2am on the busiest day of the year.

What I'd cut first

Supporting several gateways at launch. Teams add a second gateway early for redundancy, and it doubles the webhook handling, the reconciliation logic and the test surface before the first one is properly understood. Get one right, including the failure paths and the reconciliation job, and add the second when you have a measured reason — usually a specific payment method or a settlement cost you can point at.

Common questions

Can I confirm an order on the redirect back from the gateway?+

No. The customer may close the tab, lose connection, or never return at all, and the payment still succeeds. Treat the redirect as a hint to show a status page and let the webhook or a status poll decide the order state.

Why do webhooks arrive more than once?+

Gateways retry when they don't get a fast 2xx, and network conditions cause duplicates regardless. Assume at-least-once delivery and make handlers idempotent — a duplicate should be a no-op, not a second refund.

How should I store amounts?+

As integers in the smallest currency unit — paise, cents — alongside an explicit currency code. Floating point introduces rounding errors that surface during reconciliation, long after the code that caused them.

Do I still need reconciliation if webhooks work?+

Yes. A small proportion of payments will always end up in a state your system didn't record, whether from a dropped webhook, a deploy during delivery, or an asynchronous method settling late. A scheduled reconciliation job is what catches those.