Accept crypto with CoinGate
Accept crypto with confidence using everything you need in one platform.
Reconciling Crypto Payments With the CoinGate Ledger API
Every crypto integration eventually hits the same wall. Payments work, callbacks fire, orders get fulfilled, and then finance asks a simple question: does the money we received match the money in our books?
Answering that by clicking through a dashboard does not scale. What you want is a programmatic feed of every credit and debit on your account, each one traceable back to the order that caused it. That is what the crypto ledger API gives you.
This guide assumes the basics already work. If they do not, start with the crypto payment API integration guide and come back once orders are flowing. If you want the finance-side view of the same problem, crypto payment reconciliation covers the concepts without the code.
Still closing the month by clicking through a dashboard? Put your reconciliation on a schedule instead.
What the ledger actually is
Think of your CoinGate account as a set of accounts, one per currency and account type. A euro account, a Bitcoin account, a USDC account. Each holds a balance. Every time money moves, whether a customer payment lands, a fee is taken, or a refund goes out, the ledger records an entry against one of those accounts.
So there are two things to read. The accounts tell you what you hold right now. The transactions tell you every movement that got you there. Reconciliation lives in the second one.
The base URL is https://api.coingate.com/v2 and every call uses the same auth header as the rest of the API:
Authorization: Token YOUR_API_TOKEN
Two notes before you copy anything. The OpenAPI definitions on the reference pages list the server as https://api.coingate.com/api/v2, and both paths resolve, so either works. The Environments page is the one that names /v2, which is what this guide uses. And some code panes on those pages show a generic Bearer example that comes from the doc tooling rather than from CoinGate. The documented scheme is Token, not Bearer.
Build against sandbox first at https://api-sandbox.coingate.com/v2. Sandbox needs its own credentials generated on sandbox.coingate.com, because live keys do not work there.
Reading account balances
Start with the accounts. A single GET lists them all:
curl "https://api.coingate.com/v2/ledger/accounts"
-H "Authorization: Token YOUR_API_TOKEN"
You get back a paginated envelope, 100 per page by default and 100 at most. The accounts sit under an accounts key:
{
"current_page": 1,
"per_page": 100,
"total_accounts": 2,
"total_pages": 1,
"accounts": [
{
"id": "01KS2DT6X277ZEERSGKRPW8YZV",
"balance": "481.08",
"status": "active",
"account_type": "main",
"title": "Euro main account",
"currency": {
"id": 11,
"title": "Euro",
"kind": "fiat",
"symbol": "EUR",
"enabled": true,
"disabled_message": null
}
}
]
}
Three details that will bite you if you miss them.
balanceis a string, not a number. So is every amount in this API. That is deliberate, because floating-point math on money is how you end up a satoshi short. Parse them as decimals, never as floats.- The balance is floored to the currency’s precision, not rounded. For a reconciliation that has to tie to the cent, work from the transaction entries rather than treating a displayed balance as the authoritative sum.
- You are not seeing every account. Only accounts with status
activeorsuspendedare returned. An account that isdisabledorerror_creatingexists but does not appear in the list, so do not assume this response enumerates every currency you have ever held.
The numeric currency.id differs between live and sandbox. Live EUR is 11 and live BTC is 8, while in sandbox those same currencies are 2 and 1. Match on currency.symbol rather than the ID and the problem disappears.
If you already know the account ID, fetch just that one at GET /v2/ledger/accounts/{id}. Handy for a balance check on a single currency. An ID that does not belong to you comes back 404 with reason LedgerAccountNotFound rather than an empty result.
Pulling transactions, which is where reconciliation happens
Now the part that matters. List transactions:
curl "https://api.coingate.com/v2/ledger/transactions?sort=created_at_desc&per_page=100"
-H "Authorization: Token YOUR_API_TOKEN"
The envelope echoes your filters back before the data, which is useful when you are logging what a scheduled job actually asked for:
{
"current_page": 1,
"per_page": 100,
"sort": "created_at_desc",
"date_from": "",
"date_to": "",
"currency": "all",
"total_entries": 22,
"total_pages": 1,
"entries": [ ... ]
}
Note the key is entries here and accounts on the other endpoint. Each entry is a single movement of funds:
{
"id": "01KS2DT6XRJ210SZHYT36HWYS9",
"created_at": "2026-05-06T14:14:59.004Z",
"purpose": "Order ID #67983 10.99 EUR",
"type": "merchant_order",
"source_type": "Order",
"source_id": "67983",
"account": {
"id": "01KS2DT6X277ZEERSGKRPW8YZV",
"balance": "481.08",
"status": "active",
"account_type": "main",
"title": "Euro main account",
"currency": { "id": 11, "title": "Euro", "kind": "fiat", "symbol": "EUR" }
},
"currency": { "id": 11, "title": "Euro", "kind": "fiat", "symbol": "EUR" },
"credited_amount": "0",
"debited_amount": "10.99",
"closing_balance": "10.99"
}
Look at source_type and source_id. That is the reconciliation key. This entry did not add 10.99 EUR out of nowhere, it came from order 67983. You can walk from a ledger movement straight back to the order in your own system, and from there to the invoice it paid. That link is the whole game.
The nested account object tells you which ledger account the movement hit, which matters as soon as you hold more than one account in the same currency. And closing_balance gives you the running balance after that entry. A single transaction is available at GET /v2/ledger/transactions/{id} if you need to re-read one.
The amount fields point the opposite way to your intuition
This is the one thing in this API that will silently produce a wrong number, so it is worth slowing down for.
You would reasonably expect credited_amount to mean money arriving. It does not. Read the two entries the reference documentation returns for a single order, and follow the balance:
# a customer payment landing
"type": "merchant_order", "credited_amount": "0", "debited_amount": "10.99", "closing_balance": "10.99"
# the processing fee on that same order
"type": "fee", "credited_amount": "0.11", "debited_amount": "0", "closing_balance": "10.88"
The payment arrives and the balance becomes 10.99, recorded under debited_amount. The fee is then taken and the balance falls to 10.88, recorded under credited_amount. The arithmetic is not ambiguous: 10.99 minus 0.11 is 10.88.
So in this API debited_amount increases your balance and credited_amount decreases it. The naming reflects double-entry bookkeeping from the ledger’s side of the book, where your merchant balance is a liability, and it is the reverse of how most people read the words.
Two practical consequences.
- Net movement is
debited_amountminuscredited_amount, not the other way round. Getting this backwards flips the sign on every fee and every refund, which nets out to roughly twice your fee total in the wrong direction. - Anchor on
closing_balance, not on the field names. Pull a period in ascending order, walk the entries, and check that eachclosing_balanceequals the previous one plus your computed net movement. If your arithmetic and the ledger’s running balance agree on every row, your sign convention is right. If they disagree on every row, it is inverted.
That check costs one call against your own account and it settles the question permanently. Run it before you write the summing logic, not after finance queries the total.
Filtering for a clean month-end
You rarely want the whole history. The endpoint takes filters that make a monthly close straightforward:
date_fromanddate_toto bound the periodcurrencyas an ISO code such asEURorBTC, to close one currency at a timetypeto narrow to one kind of movement, for examplefeeormerchant_ordersource_typetogether withsource_idto pull every movement tied to a single order. These two must be sent as a pairsort, eithercreated_at_descorcreated_at_ascpageandper_pagefor pagination, 100 per page maximum
A month-end reconciliation for euro looks like this:
curl "https://api.coingate.com/v2/ledger/transactions?currency=EUR&date_from=2026-05-01&date_to=2026-05-31&sort=created_at_asc&per_page=100"
-H "Authorization: Token YOUR_API_TOKEN"
Send the dates as YYYY-MM-DD. Anything the API cannot parse comes back 422 with reason IncorrectDateRangeError and echoes the offending value, so a malformed date fails loudly instead of quietly returning the wrong window. Worth handling explicitly in a scheduled job, because a 422 that nobody catches looks exactly like a month with no transactions.
Do not reconcile against a single transaction type
The type filter is genuinely useful, and it is also the easiest way to build a total that is quietly incomplete.
There are forty values in the documented enum, and they are not all the happy path. Alongside merchant_order, fee and merchant_refund there are reversal types: merchant_order_revert, fee_revert, merchant_refund_revert, withdrawal_revert and others. There are also fee variants you may not have thought about, including conversion_fee, flat_fee, outgoing_fee, tx_network_fee and exchange_fee.
Reversals are rare, which is exactly what makes them dangerous. A job that filters type=merchant_order and sums the result will agree with the ledger for months, then disagree by one order in the month a payment gets reverted. The account balance will be right and your total will not be.
So do not filter by type when you are reconciling. Pull every entry for the period, group by type afterwards, and let closing_balance be the arbiter. Use the type filter for reporting questions, such as what you paid in fees last quarter, where a deliberately narrow slice is the point.
A reconciliation loop that balances
Put together, the pattern is short:
import requests
from decimal import Decimal
BASE = "https://api.coingate.com/v2"
HEADERS = {"Authorization": "Token YOUR_API_TOKEN"}
def fetch_transactions(currency, date_from, date_to):
page, entries = 1, []
while True:
r = requests.get(f"{BASE}/ledger/transactions", headers=HEADERS, params={
"currency": currency, "date_from": date_from, "date_to": date_to,
"sort": "created_at_asc", "per_page": 100, "page": page,
})
r.raise_for_status()
data = r.json()
entries += data["entries"]
if page >= data["total_pages"]:
break
page += 1
return entries
running = None
for tx in fetch_transactions("EUR", "2026-05-01", "2026-05-31"):
# debited increases the balance, credited decreases it
net = Decimal(tx["debited_amount"]) - Decimal(tx["credited_amount"])
closing = Decimal(tx["closing_balance"])
# prove the sign convention on every row instead of trusting it once
if running is not None and running + net != closing:
raise AssertionError(f"ledger drift at {tx['id']}: {running} + {net} != {closing}")
running = closing
if tx["source_type"] == "Order":
match_to_invoice(tx["source_id"], net)
Three things are doing work there. Amounts go through Decimal rather than float. The net movement is debited minus credited. And the assertion re-proves the sign convention on every single row, so the day the API changes or your assumption turns out wrong, the job fails instead of filing a plausible wrong number.
Start running from the closing balance of the last entry before your window rather than from the first row of it, if you want the check to cover the opening balance too. Pull one page with date_to set to the day before the period and sort=created_at_desc.
The match_to_invoice step is yours. It is where the ledger meets your accounting system. Everything before it is just reading a clean, ordered feed of truth.
If you are not ready to automate this yet, the dashboard exports the same transactions to CSV, which covers a manual month-end perfectly well. The API is what turns it into a job that runs without you.
Frequently asked questions
What is the CoinGate Ledger API for?
It exposes your account balances and every transaction on your account programmatically. The main use is reconciliation: matching the credits and debits CoinGate recorded against the orders and invoices in your own system.
How do I link a ledger transaction back to an order?
Each transaction carries a source_type and source_id. When source_type is Order, the source_id is the CoinGate order ID, which you can map to your internal order and invoice records. You can also query the pair directly to pull every movement for one order.
Does credited_amount mean money coming in?
No, it is the other way round. debited_amount increases your balance and credited_amount decreases it, which you can confirm by following closing_balance across two entries on the same order. Net movement is debited_amount minus credited_amount.
Why are balances and amounts returned as strings?
To avoid floating-point rounding errors on money. Parse them as decimals in your code, never as floats, or you risk being off by tiny fractions that break reconciliation.
Can I reconcile one currency at a time?
Yes. The transactions endpoint accepts a currency filter alongside date_from and date_to, so you can pull a single currency for a single period and close it independently.
Should I filter by transaction type when reconciling?
No. Reversal types such as merchant_order_revert and fee_revert are real and rare, so a type filter produces a total that matches for months and then quietly does not. Pull everything for the period and group afterwards.
Do I need special access to use the Ledger API?
No. It uses the same API token as the rest of the CoinGate API. Generate sandbox credentials at sandbox.coingate.com to build and test before going live.
Wrapping up
The Ledger API turns reconciliation from a manual chore into a scheduled job. Read the accounts for balances, pull the transactions for movements, and lean on source_type and source_id to walk every euro or satoshi back to the order that produced it.
Then get the two details right that decide whether the total is trustworthy. Amounts are strings, so parse them as decimals. And debited_amount is money in, not money out, which you should prove against closing_balance rather than take on faith. Do that, and your month-end close becomes something your server does while you sleep.
For the accounting treatment behind the numbers, including how fiat value and disposal events are handled, accounting for crypto payments covers the finance view.
Ready to reconcile crypto payments with a scheduled job instead of a spreadsheet? Start with us.
Accept crypto with CoinGate
Accept crypto with confidence using everything you need in one platform.