Reconciling payments and payouts
Match every ZyloPay payment and refund to your orders, close each day, and check the balance.
Reconciliation answers three questions: did every order get paid once, does every payment belong to an order, and does the money add up?
Match payments to orders
Each payment carries several keys you can join on:
| Field | Use |
|---|---|
payment_intent | The intent you created. Store the intent ID with your order when you create it. |
order_id | Your reference, written on-chain with the settlement. Pass your own when you create the intent (1-8 characters). |
terminal | Which terminal took it. |
metadata (on the intent) | Your own keys, for example { "pos_order": "48213" }. |
tx_hash | The Monad transaction, verifiable on the explorer. |
For terminal-started payments, order_id is generated by the terminal; match them by terminal and time, or by the intent (source: "terminal").
Record payments as they happen
Listen to payment.succeeded and payment.refunded and upsert your ledger by payment ID. Webhooks are at least once and unordered, so write idempotently and keep the latest amount_refunded (it only grows).
Close a day
Walk each day in UTC with a closed window, after the day has ended:
const window = { created_gte: '2026-09-28T00:00:00Z', created_lt: '2026-09-29T00:00:00Z' };
let gross = 0n;
let fees = 0n;
for await (const p of zp.payments.list({ ...window, limit: 100 })) {
gross += BigInt(p.amount);
fees += BigInt(p.fees.acquirer + p.fees.protocol + p.fees.cashback);
await ledger.upsertPayment(p); // idempotent
}
let refunded = 0n;
for await (const r of zp.refunds.list({ ...window, limit: 100 })) {
if (r.status === 'succeeded') refunded += BigInt(r.amount);
await ledger.upsertRefund(r);
}- A payment's
createdis when ZyloPay indexed it, about a second aftersettled_at(the block time). Filter oncreated; usesettled_atfor reporting if your books use chain time. - A refund can be
pendingfor a short while. Re-read pending refunds before you close, or close the day once none is pending. - Refunds belong to the day they were made, not the day of the payment.
feesare paid by the customer, not by you.netequalsamount.
Check the balance
Your balance in the settlement contract moves like this:
balance(end) = balance(start) + payments.amount − succeeded refunds − withdrawalsRead it with GET /v1/balance (live from the chain, with as_of). Withdrawals are made in the dashboard and are not in the API today; take them from the dashboard's withdrawal history or from the settlement contract's FundsWithdrawn events for your merchant. Record the balance at the same time each day to compare.
Verify on-chain
For audit, every payment can be verified independently: look up tx_hash on the Monad explorer and check the settlement contract's event for your merchant, amount and order_id. See Settlement.
Common mismatches
| Symptom | Likely cause |
|---|---|
| Order paid twice | Two intents for one order. Use an idempotency key derived from the order, and cancel the old intent before creating a new one. |
| Payment without an order | A sale started on the terminal. Match by terminal and time, or train staff to enter the order reference. |
| Order unpaid but a payment exists | A missed webhook. Run the daily walk, or catch up from GET /v1/events. |
| Totals off by fees | You summed total_charged instead of amount. You receive amount. |