Accepting payments is the visible part of a payment integration. Reconciliation is the part finance teams care about: proving that every rupee captured, refunded and charged as fees matches what landed in the bank account.
This guide explains how settlements work with Indian gateways, which data to pull, and how to design a batch job that matches it all. The approach applies to Razorpay, Cashfree, PayU and others. Razorpay's settlement APIs are used as the concrete example.
Three ledgers that must agree
Reconciliation compares three independent records:
- Your application database: orders, payments, refunds as your system understands them.
- The gateway's records: captured payments, refunds, fees, tax, adjustments and the settlements that group them.
- Your bank statement: the actual credits, each identified by a UTR (unique transaction reference).
Matching 1 to 2 tells you whether your app recorded every transaction correctly. Matching 2 to 3 tells you whether the money arrived.
How a settlement is built
Gateways do not send each payment to your bank individually. They batch transactions into settlements on a schedule. For Indian domestic payments, Razorpay's settlement FAQs describe a default T+2 working day cycle, where T is the capture date. Working days exclude Sundays, the second and fourth Saturdays, and bank holidays, so long weekends shift settlements.
Conceptually, each settlement amount is:
settled = captured payments
- gateway fees
- tax on fees
- refunds
+/- adjustments
Only captured payments are settled. Authorized but uncaptured payments do not appear.
Pulling settlement data
The most useful endpoint for reconciliation returns line items, not just totals. Razorpay's settlement recon API is GET /v1/settlements/recon/combined with year and month as required parameters, plus optional day, count (up to 1000) and skip for pagination.
Each record includes fields such as entity_id, type (payment, refund, transfer or adjustment), debit, credit, amount, fee, tax, settled, settlement_id, settlement_utr and order_receipt. All amounts are in paise.
import requests
URL = "https://api.razorpay.com/v1/settlements/recon/combined"
def fetch_recon(key_id, key_secret, year, month, day):
rows, skip = [], 0
while True:
resp = requests.get(URL, auth=(key_id, key_secret), params={
"year": year, "month": f"{month:02d}", "day": day,
"count": 1000, "skip": skip,
}, timeout=30)
resp.raise_for_status()
items = resp.json().get("items", [])
rows.extend(items)
if len(items) < 1000:
return rows
skip += 1000
The call uses HTTP basic authentication with your API key ID and key secret, so run it only on the server and load the credentials from a secrets store. Paginate until a page returns fewer rows than you asked for, because a busy day can easily exceed a single page.
Store these rows as is, in a staging table, before transforming them. Raw data makes later investigations much easier.
The order_receipt field is worth planning for at integration time. If you pass your internal order ID as the receipt when creating orders, every settled payment row carries a direct pointer back to your database.
Fees and GST
Gateway fees are deducted at source, and GST is charged on the fee. In Razorpay's payment entity, fee is the total charged including tax, and tax is the tax component. In the recon data, fee is the processing fee and tax is the tax on that fee.
Two practical points:
- Do not hard-code a fee percentage in your reconciliation logic. Rates vary by method, card type and negotiated pricing. Read the fee from the data.
- Keep fee and tax as separate columns. Finance teams typically need the tax total to tie back to the gateway's tax invoice for the period.
Your expected net for a payment is amount minus fee, where fee already includes tax. Getting that relationship wrong, by subtracting tax twice, is a common source of small, persistent mismatches.
Refunds and adjustments
Refunds appear as debit rows in settlement data, linked to the original payment through payment_id. They reduce the settlement for the period in which they are processed, not the period of the original payment.
Track refund status separately from payment status. Razorpay refunds move through pending, processed and failed, and its refund webhooks include refund.created, refund.processed, refund.failed and refund.speed_changed. A payment's refund_status of partial or full tells you how much has been reversed overall.
Adjustments are the catch-all: corrections and other manual entries, which can include dispute-related debits depending on the gateway. Never auto-match them. Route every adjustment row to a human review queue with its description.
Matching bank credits
The settlement UTR is the bridge between gateway and bank. When Razorpay transfers a settlement, it fires the settlement.processed webhook, described in the settlement webhook reference, with the settlement id, amount, fees, tax and utr.
Match on UTR first, then confirm the amount:
def match_bank_credit(stmt_line, settlements_by_utr):
s = settlements_by_utr.get(stmt_line.utr)
if s is None:
return ("unmatched_bank_credit", stmt_line)
if s.amount != stmt_line.amount_paise:
return ("amount_mismatch", s, stmt_line)
return ("matched", s)
Bank statement formats vary. Normalise the UTR (trim spaces, uppercase) and convert amounts to paise before comparing, so floating point rupees never enter the comparison.
Designing the batch job
A daily job works well for most businesses. A reasonable shape:
- Pull recon rows for the previous few days, not just yesterday, to catch late entries.
- Upsert into staging keyed by entity_id and settlement_id, so reruns are safe.
- Match payment rows to your orders using order_receipt or the gateway order ID.
- Match refund rows to your refund records using the refund ID.
- Sum each settlement's rows and compare with the settlement amount.
- Match settlements to bank credits by UTR.
- Write every unmatched or mismatched item to an exceptions table with a reason code.
Run the job in IST and store timestamps in UTC. Date boundaries are a frequent cause of rows appearing in the "wrong" day.
Common mismatch causes
- A payment captured in your app but not yet settled, because of the T+2 cycle or a holiday.
- A refund initiated from the dashboard and never recorded in your database.
- Partial settlements, where only part of the pending amount is settled in one transfer.
- Transactions on hold, flagged by on_hold in recon data.
- Late-authorised payments that your app recorded as failed.
Each of these resolves itself or needs a known action. The exceptions table should make the next step obvious.
Summary
Good reconciliation is mostly data hygiene: pass your own IDs to the gateway, store raw settlement rows, work in paise, keep fee and tax separate, and match bank credits by UTR. Build the job to be rerunnable, and route anything ambiguous to people rather than guessing.
Disclosure: The author works with the Razorpay team on content.
Top comments (0)