Automated payment reconciliation: stop closing the books by hand
How to automate reconciliation with the Pagnovo API — externalId, transaction lookup, balance by category, x-trace-id and the routine that closes the day for you.
Pagnovo Team · 2026-08-06
Reconciliation answers a simple question: does what the system says I received match what actually landed? Done by hand in a spreadsheet, the cost shows up in three ways — hours of work, human error, and the late discovery of paid orders that were never delivered.
This guide shows how to automate it.
The mistake that makes reconciliation hard later
The most important decision happens when you create the charge, not at month-end: sending the
externalId.
POST /transactions/v2/purchase
{
"amount": 18990,
"externalId": "order-1042",
"description": "Order #1042"
}
The externalId is the order identifier in your system. Without it you depend on storing
Pagnovo's ID somewhere — and if that record is lost (a failure mid-request, a timeout, a bad deploy),
the transaction becomes an orphan: money received with no matching order.
With externalId, lookups work both ways, and the same endpoint accepts three keys:
GET /transactions/:id
It resolves by Pagnovo's ID, by your externalId, or by the PIX end2End. That covers
practically any investigation scenario.
The three sources that must match
Honest reconciliation compares three things:
| Source | What it answers |
|---|---|
| Your database | What I expected to receive |
| Pagnovo API | What was actually processed |
| Account balance | How much is genuinely available |
If you only compare the first two, you miss held amounts. That is why the third one matters.
Balance: not everything that came in is available
GET /accounts/balance
The response splits the balance into categories — and that split is what prevents month-end surprises:
- Available — withdrawable now
- In settlement — transacted, not yet settled
- Collateral deposits
- Precautionary blocks
- Chargeback reserves
A common mistake is summing everything and treating it as cash. It is not: chargeback reserves and precautionary blocks exist but cannot be used. Your reconciliation should treat each category separately, and your cash flow should look only at the available balance.
The reconciliation routine
A design that works well in practice, running a few times a day:
// 1. Orders I created but have not confirmed
const pending = await db.orders.find({
status: 'PENDING',
createdAt: { $gte: yesterday },
});
for (const order of pending) {
// 2. Ask the source of truth, using MY identifier
const tx = await pagnovo.get(`/transactions/${order.externalId}`);
// 3. Reconcile the state
if (tx.status === 'APPROVED' && order.status !== 'PAID') {
await releaseOrder(order, tx); // webhook failed — recovered here
}
if (['REJECTED', 'BLOCKED'].includes(tx.status)) {
await markFailed(order, tx.status);
}
if (tx.status === 'INCONSISTENT') {
await flagForHuman(order, tx); // do not decide on your own
}
}
Three principles are baked in:
- The API is the source of truth, not your database.
- The job repairs what the webhook missed. Webhooks are the fast path; reconciliation is the net.
INCONSISTENTis not resolved automatically. Ambiguous state goes to human review — with money, guessing is expensive.
The states and what to do with each
| Status | Meaning | Action |
|---|---|---|
PENDING |
Awaiting payment | Keep watching, respect expiry |
APPROVED |
Paid | Release order (idempotently) |
REJECTED |
Declined | Close, offer a retry |
INCONSISTENT |
Discrepancy | Manual review |
BLOCKED |
Blocked | Review + contact support |
Pagination: do not lose records in the middle
When listing, the API uses zero-indexed pagination, with a default limit of 20 and a maximum of 100.
?page=0&limit=100
The classic mistake is reading only the first page and concluding "there were 20 transactions in the period". Always walk to the last page before closing any number.
x-trace-id: what saves your support team
Every API response includes the x-trace-id header. Store it alongside the transaction record.
When a "the customer swears they paid" case appears, you open a ticket with the x-trace-id and the
team finds exactly that request in the logs — instead of an approximate investigation using time and
amount.
It is one line in your HTTP client that saves hours later.
Testing the reconciliation flow
In the sandbox (sk_test_*), amounts produce deterministic outcomes — including the awkward ones:
| Amount | State produced |
|---|---|
| R$ 10.00 | Approved |
| R$ 10.01 | Rejected |
| R$ 10.02 | Inconsistent |
| R$ 10.04 | Chargeback |
| R$ 10.05 | Refund |
| R$ 10.06 | Blocked |
So you can deliberately exercise the INCONSISTENT and chargeback paths — exactly the ones that
break sloppy reconciliation. The test environment allows 30 operations per day.
Checklist
-
externalIdsent on every charge -
x-trace-idstored with the transaction - Periodic job sweeping pending orders
- Idempotent order release (webhook and job must not duplicate)
-
INCONSISTENTandBLOCKEDrouted to human review - Pagination walked to the end
- Balance read by category, not summed
- Alert when the pending queue grows abnormally
Explore our Billing platform and the API documentation. Need help designing your reconciliation? Talk to our team.