Home · About · Services · Industries · Projects · Products · Process · Blog · FAQ · Contact
Payment gateway integration: webhooks, retries and reconciliation How to integrate Stripe, Razorpay or any payment gateway so no order is lost: signed webhooks, idempotent processing, safe retries and daily reconciliation.
A customer pays, the bank debits their account, and your shop still shows the order as unpaid. Usually the browser closed before the redirect back to your site finished, and nothing else was listening. It is a common bug in inherited e-commerce code, and it comes from treating the checkout redirect as the source of truth.
The short answer: a reliable payment gateway integration treats the gateway's signed webhooks as the source of truth, records every event once using its event ID, makes order updates idempotent, retries outbound calls with idempotency keys, and runs a daily reconciliation against the gateway's settlement data to catch anything that slipped through.
The examples use Stripe and Razorpay, but the pattern is the same for PayU, Cashfree, PayPal or Adyen.
Model the order as a state machine Before writing gateway code, decide which states an order can be in and which events move it between them. Otherwise you end up with flags like is_paid and payment_done that disagree.
pending: order created, gateway order or payment intent created, nothing paid yet. authorised: the bank has approved the amount but it has not been captured (only if you use manual capture). paid: money captured. This is the only state that releases stock or starts fulfilment. failed: the attempt failed. The customer may retry, so failed can still move to paid. refunded or partially_refunded: driven by refund events, never by an admin button alone. disputed: a chargeback was opened. Fulfilment and support need to know. Two rules keep this sane. Transitions only move forward, so a late payment.failed event can never overwrite paid. And amounts are stored as integers in the smallest currency unit, paise or cents, together with the currency code. Floating-point rupees cause rounding differences that make reconciliation miserable.
Do not trust the redirect After checkout the gateway sends the customer to your success URL, often with a payment ID and signature. Use that only to show a friendly page. The redirect can be lost, replayed or tampered with, and for methods such as UPI or net banking the final status may arrive minutes later.
If you do read the redirect parameters, verify them on the server. With Razorpay Standard Checkout, the signature is an HMAC-SHA256 of order_id + "|" + payment_id using your key secret. With Stripe, fetch the Checkout Session or PaymentIntent by ID from your server and read its status, rather than believing a query string. Even then, mark the order paid only through the same code path the webhook uses, so there is one place that decides.
Receive webhooks properly Gateways deliver webhooks at least once, possibly out of order, and retry when you do not answer quickly with a 2xx status. Stripe, for example, retries failed deliveries in live mode for up to three days with exponential backoff, and rejects signatures older than five minutes by default in its libraries. Your endpoint has three jobs: verify the signature, store the event, and return 200. Everything else happens in a background worker.
Signature checks need the raw request body. If a JSON body parser runs first and re-serialises the payload, the HMAC will not match. In Express, mount the raw parser on the webhook route only:
import express from 'express';
import crypto from 'node:crypto';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
// Stripe: the library checks the Stripe-Signature header and timestamp
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET);
} catch {
return res.status(400).send('bad signature');
}
await saveEvent('stripe', event.id, event.type, req.body.toString('utf8'));
res.sendStatus(200);
});
// Razorpay: HMAC-SHA256 of the raw body with the webhook secret
app.post('/webhooks/razorpay', express.raw({ type: 'application/json' }), async (req, res) => {
const expected = crypto.createHmac('sha256', process.env.RAZORPAY_WEBHOOK_SECRET)
.update(req.body).digest('hex');
const given = String(req.headers['x-razorpay-signature'] || '');
const ok = given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
if (!ok) return res.status(400).send('bad signature');
const payload = JSON.parse(req.body.toString('utf8'));
const eventId = req.headers['x-razorpay-event-id'] ||
crypto.createHash('sha256').update(req.body).digest('hex');
await saveEvent('razorpay', eventId, payload.event, req.body.toString('utf8'));
res.sendStatus(200);
});
// Everything else (JSON parser, routes) goes after the webhook routes
app.use(express.json());Note the constant-time comparison for the hand-rolled HMAC. Keep webhook secrets separate from API keys, store both outside the code, and rotate them when someone with access leaves. Subscribe only to the events you handle; it reduces noise and load.
Make processing idempotent The same event can arrive twice, and a webhook and a redirect check can run at once, so every handler must be safe to repeat. The simplest tool is a database unique constraint.
CREATE TABLE payment_events (
provider text NOT NULL,
event_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,
error text,
PRIMARY KEY (provider, event_id)
);
-- saveEvent(): a duplicate delivery becomes a no-op
INSERT INTO payment_events (provider, event_id, event_type, payload)
VALUES ($1, $2, $3, $4)
ON CONFLICT (provider, event_id) DO NOTHING;A worker then picks up rows where processed_at is null, applies the state transition inside a transaction, and marks the row processed. The transition itself is guarded, for example UPDATE orders SET status = 'paid' WHERE id = $1 AND status IN ('pending', 'authorised', 'failed'). If zero rows change, the order was already paid and the event is simply recorded. Check the amount and currency in the event against the order before marking it paid; a mismatch should go to a review queue, not to fulfilment.
Keeping the raw payload also means that a month later you can see exactly what the gateway told you, and when.
Retries in both directions Inbound retries are handled by the gateway, as long as your endpoint fails honestly. Return a non-2xx status only when you could not store the event. If storage worked but processing failed, return 200 and let your own worker retry, with backoff and a maximum attempt count, then alert a person.
Outbound calls need the same care. A create, capture or refund call can time out after the gateway has already acted; a blind retry can refund twice. Stripe supports an Idempotency-Key header on POST requests: send the same key on a retry and you get the original result instead of a second operation. Derive the key from your own data, such as refund-<order_id>-<attempt>, and store it before making the call. Where a gateway has no idempotency header, look up the existing object by your own reference (Razorpay orders carry a receipt field, and most gateways accept notes or metadata) before creating a new one.
Set client timeouts explicitly; library defaults can be long. Retry only on network errors, timeouts and 5xx responses, never on 4xx validation errors. Use exponential backoff with jitter and a small cap, three to five attempts. Poll orders stuck in pending for more than, say, 30 minutes by fetching their status from the gateway API. This catches missed webhooks before customers complain. Reconcile every day Webhooks and retries make the system mostly right; reconciliation proves it. Once a day, pull the gateway's records and compare them with yours. Stripe exposes balance transactions and payouts, plus reconciliation reports in the dashboard; Razorpay has a settlement reconciliation API that lists each payment, refund and fee in a settlement. Load that data into a staging table and compare.
-- Payments the gateway captured that we do not show as paid
SELECT g.payment_id, g.amount, g.order_ref
FROM gateway_settlement_lines g
LEFT JOIN orders o ON o.gateway_order_id = g.order_ref
WHERE g.type = 'payment'
AND (o.id IS NULL OR o.status NOT IN ('paid', 'refunded', 'partially_refunded'));
-- Orders we show as paid with no matching captured payment
SELECT o.id, o.amount_minor
FROM orders o
LEFT JOIN gateway_settlement_lines g
ON g.order_ref = o.gateway_order_id AND g.type = 'payment'
WHERE o.status = 'paid'
AND o.paid_at < now() - interval '3 days'
AND g.payment_id IS NULL;Mismatches usually mean a missed webhook, a payment captured against an expired order, a refund issued from the gateway dashboard, or an amount difference. Fees and taxes on fees appear in settlements but not in order totals, so record them separately and compare net settled amounts with the bank credit. Settlements usually arrive a day or more after capture, which is why the second query above allows a lag. Send the results to a person daily, even when empty, so a silent job failure gets noticed.
Testing and go-live checklist Use the gateway's test mode to send events to a staging endpoint. The Stripe CLI forwards events to a local server with stripe listen --forward-to localhost:3000/webhooks/stripe, and stripe trigger payment_intent.succeeded fires a test event. Then deliberately break things: send the same event twice, deliver paid before created, kill the worker mid-transaction, and let a test payment sit in pending.
Order state machine written down, with forward-only transitions. Amounts stored as integers in minor units with a currency code. Webhook route uses the raw body, verifies signatures and returns 200 quickly. Events stored with a unique (provider, event ID) key and processed by a worker. Amount and currency checked against the order before marking it paid. Idempotency keys or reference look-ups on every create, capture and refund call. Pending orders polled; failed processing alerts a person. Daily reconciliation against settlement data, with results reviewed. Card data handled only by the gateway's hosted checkout or fields, which keeps it off your servers and shrinks your PCI DSS scope. Live and test keys separated per environment, and webhook endpoints registered for each. If your integration fails several of these, the fix is rarely a rewrite. An events table, a guarded state update and a daily reconciliation query close most of the gaps. Do it before the next sale weekend, not after.
Infigini IT Services · 11 Kanwali Road, Dehradun 248001, India · info@infigini.com · +91 95684 87789