I am Sajan Acharya, a Senior Software Engineer based in Kathmandu. Idempotency stops many double charges at the door, but it does not replace reconciliation. Webhooks get delayed, settlement batches disagree with your database, refunds land without matching order IDs, and manual dashboard captures bypass your API. Payment reconciliation is the process of comparing your internal ledger to the payment gateway’s truth—then resolving every mismatch with an owned workflow. This how-to covers how I implement reconciliation for Node.js backends that cannot treat “mostly in sync” as good enough.
Build reconciliation on top of solid write paths. If you have not locked down retries yet, start with how to implement idempotency keys in payment gateways. Prevention plus detection beats either alone. For production API and worker delivery, see Node.js developer services.
Define the two ledgers you will match
Your internal ledger is whatever your product trusts for order status: payments table, wallet entries, or accounting events with amount, currency, status, provider payment ID, idempotency key, and timestamps. The gateway ledger is their API list of charges/refunds, webhook history, or daily settlement/payout files. Reconciliation fails when either side lacks a stable join key. Persist the provider’s payment ID as soon as you know it. Persist your merchant reference (order ID) on every gateway call so settlement rows can find home.
Agree on status vocabulary. Map gateway states—authorized, captured, failed, refunded, disputed—onto your domain statuses explicitly. Do not compare raw strings from three different PSP docs without a normalization layer. Store amounts as integers in minor units to avoid float drift during matching.
Ingest gateway truth on a schedule
Run a worker that pulls transactions for a time window—usually yesterday plus a lookback of a few days for late updates. Prefer official settlement reports when the gateway provides them; otherwise page through list APIs with cursor pagination and rate-limit respect. Store raw payloads in an immutable import table, then project normalized rows into a gateway_transactions table. Raw storage saves you when mapping bugs appear weeks later.
- Import window: rolling lookback, not only “today,” to catch late settles
- Upsert by provider transaction ID so re-runs are safe
- Normalize currency, amount, fees, and net separately when available
- Record import batch IDs for audit and reprocessing
- Alert when an import job skips or returns empty on a business day
Match, classify, and queue exceptions
Matching should be automatic for the happy majority. Primary match: provider payment ID equality. Secondary match: merchant reference plus amount and currency within the same day window. Tertiary heuristics—fuzzy time and amount—belong in a review queue, not silent auto-apply. Emit outcomes: matched, internal_only (you have a payment the gateway does not), gateway_only (gateway has money movement you do not), and conflict (same ID but amount or status disagrees).
Each exception type needs an owner playbook. Internal_only may be a pending webhook, a sandbox leak, or a failed write after a successful charge—investigate with the idempotency record and provider dashboard. Gateway_only may be a dashboard manual charge or a missed webhook—create or link a payment carefully, never by guessing the order. Conflicts demand human review before you overwrite financial state. Keep an exceptions table with status open, investigating, resolved, and the resolving actor.
Near-real-time signals versus batch settlement
Webhooks are a stream; settlement is a batch. Use webhooks to update UX quickly, but treat daily reconciliation as the financial close. Deduplicate webhook events by event ID the same way you dedupe charges with idempotency keys. If a webhook says paid and settlement later says failed or reversed, reconciliation must reopen the case—do not assume the first event was final. Refunds and chargebacks deserve their own matchers against the original payment ID.
Scale the workers like any other async pipeline: queues, retries, and visibility timeouts. Guidance in how to scale APIs with Node.js applies when settlement files grow or you add multiple PSP accounts. Keep reconciliation jobs isolated from the request path so a slow import never blocks checkout.
Controls, reports, and team habits
Ship a daily reconciliation report: matched count, exception counts by type, total amount variance, and aging open exceptions. Finance and engineering should share one dashboard. Set SLAs—for example, gateway_only items older than 24 hours page on-call. Write runbooks with screenshot-level steps for your specific PSP. Without runbooks, reconciliation becomes a hero culture instead of a system.
- Never delete financial events; void or reverse with new events
- Require dual control or audit logs when manually linking payments to orders
- Reconcile fees and taxes if your books need net revenue, not only gross
- Test with seeded mismatches in staging before trusting production jobs
- Version your status mapping when the gateway changes enums
Close the loop with architecture that can evolve
Keep reconciliation in its own module—importers, matchers, exception use cases—so checkout code stays readable. Clean module boundaries from how to structure a Node.js backend with clean architecture help when you add a second gateway. Contract clarity from how to design scalable Node.js APIs helps when finance tools consume reconciliation summaries via API.
From Kathmandu I help product teams turn gateway CSV panic into scheduled matching with clear exception queues. If your ledger and PSP reports already disagree, get in touch with your gateway, payment schema, and the worst mismatch examples you have. We can implement import, match, and resolution workflows so reconciliation is a daily habit—not a month-end fire drill.