Import a bank statement and reconcile it
The bank sends the month's statement as an ISO 20022 camt.053 file. Importing it stores every transaction as a bank line, and the matcher settles the lines it can identify with certainty. A person (or an agent) then decides about the rest: match a payment to the open invoice or bill it belongs to, book a bank fee straight to an expense account, or ignore a line that needs no booking. When no line is left unmatched, the bank account in the ledger agrees with the bank. This walkthrough does that for September 2026 of the Swiss entity CH01. It is for finance people who reconcile, and for integrations that feed statements in.
Before you start
- Scopes:
draftto import, match and post lines,postto ignore and reopen lines,readfor the lists.$ERP_KEYholds them all. - The demo organization: CH01 (Demo AG, CHF) has the bank account
1020registered under the IBANCH93 0076 2011 6238 5295 7. Its August statement is already imported and reconciled; September is open. The statement file used here,files/camt053-2026-09.xml, is for the same account, has its own statement id (DEMO-2026-09) and its own transaction references, and holds four September transactions. - The statement is matched against what the ledger knows, so the first two steps enter a customer invoice and a vendor bill that the statement pays.
1. Enter the invoice and the bill the statement pays
An invoice to the customer C-CH for 1000.00 plus 8.1% VAT, 1081.00 in all. The bank statement will
show a credit of that amount, but paid by a fiduciary on the customer's behalf and without the
invoice number, so the matcher cannot be sure which invoice it is.
curl -X POST "$ERP_URL/v1/documents" \
-H "Authorization: Bearer $ERP_KEY" \
-H "Idempotency-Key: bank-invoice-1" \
-H "Content-Type: application/json" \
-d '{
"entity": "CH01",
"kind": "sales_invoice",
"party": "C-CH",
"document_date": "2026-09-01",
"lines": [{"description": "Beratung September", "unit_price": "1000"}]
}'
{
"document": {"status": "posted", "party_code": "C-CH", "total": "1081.00", "open_amount": "1081.00"},
"submit": {"status": "posted"}
}
A bill from the vendor V-BM with the vendor's own invoice number BM-5002, 500.00 plus VAT, 540.50
in all. The statement's debit names that number in its payment text, so this one can be matched
automatically.
curl -X POST "$ERP_URL/v1/documents" \
-H "Authorization: Bearer $ERP_KEY" \
-H "Idempotency-Key: bank-bill-1" \
-H "Content-Type: application/json" \
-d '{
"entity": "CH01",
"kind": "purchase_bill",
"party": "V-BM",
"document_date": "2026-09-02",
"external_number": "BM-5002",
"lines": [{"description": "Büromaterial", "unit_price": "500", "supply_type": "goods", "account": "6500"}]
}'
{
"document": {"status": "posted", "party_code": "V-BM", "total": "540.50", "open_amount": "540.50"},
"submit": {"status": "posted"}
}
2. Import the statement
POST /v1/entities/CH01/bank-statements takes a multipart form: the file in the field file and
format (camt053, the default, camt054, mt940 or csv). The account is found from the statement's
IBAN (the one registered for a GL bank account); account names the GL account explicitly. A file
can hold several statements, so the answer is a list with one result per statement.
The four transactions of the file are:
| Date | Amount | Text | What it is |
|---|---|---|---|
| 14 Sep | +1081.00 | "Zahlung Beratung September", paid by "Treuhand Muster AG" | the customer payment, without the invoice number |
| 15 Sep | -540.50 | "Rechnung BM-5002" | the vendor bill |
| 28 Sep | -25.00 | "Kontofuehrung September" | a bank fee |
| 29 Sep | -2000.00 | "Umbuchung Sparkonto" | a transfer to the company's own savings account |
The matcher settles a line when it is unambiguous: a QR or ISO 11649 reference on a credit, or a
reference text that names exactly one open document and the amount settles it (debits of a payment
file the ERP released are found by their end-to-end id). A matched line creates an ordinary payment
that goes through the approval policy. Here the debit names BM-5002 and the amount is the bill's
open amount, so it is matched. The credit has the right amount but neither a reference nor a payer
that identifies the customer, and the matcher leaves it to a person rather than guess.
curl -X POST "$ERP_URL/v1/entities/CH01/bank-statements" \
-H "Authorization: Bearer $ERP_KEY" \
-F "format=camt053" \
-F "file=@files/camt053-2026-09.xml"
[
{
"statement_id": "<uuid>",
"statement_ref": "DEMO-2026-09",
"imported": 4,
"duplicates": 0,
"matched": 1,
"unmatched": 3
}
]
duplicates counts transactions that were skipped because their bank reference was imported
before. With auto_match=false in the form, every line stays unmatched.
3. See what is left
GET /v1/entities/CH01/bank-lines lists the lines by status (unmatched is the default;
matched, ignored and all also work) in booking-date order, optionally for one statement.
Amounts are signed: positive is money in, negative money out.
curl "$ERP_URL/v1/entities/CH01/bank-lines?status=unmatched&statement=$STATEMENT_ID" \
-H "Authorization: Bearer $ERP_KEY"
[
{
"booking_date": "2026-09-14",
"amount": "1081.00",
"currency": "CHF",
"counterparty_name": "Treuhand Muster AG",
"remittance": "Zahlung Beratung September",
"status": "unmatched",
"match": null
},
{"booking_date": "2026-09-28", "amount": "-25.00", "status": "unmatched"},
{"booking_date": "2026-09-29", "amount": "-2000.00", "status": "unmatched"}
]
4. Look at the candidates and match by hand
GET /v1/bank-lines/{id}/candidates lists the open documents that could settle a line, best first,
at most ten. For a credit these are sales invoices, for a debit vendor bills, in any currency: one in
another currency than the line's also gives its open amount at the booking day's rate (line_open).
Each candidate gives its open amount, any cash discount still available on the booking date, a
score and the reasons for it (structured reference 100, a document number in the text 50,
exact open amount 30, amount less the cash discount 30, IBAN 25, counterparty name 15).
curl "$ERP_URL/v1/bank-lines/$LINE_CREDIT/candidates" \
-H "Authorization: Bearer $ERP_KEY"
[
{
"document_id": "<uuid>",
"kind": "sales_invoice",
"party_code": "C-CH",
"due_date": "2026-10-01",
"currency": "CHF",
"open": "1081.00",
"cash_discount": "0.00",
"score": 30,
"reasons": ["exact open amount"]
}
]
One candidate, and only the amount points to it. A person who knows that the fiduciary pays for
this customer confirms it with POST /v1/bank-lines/{id}/match. The allocations say which
documents the line pays and how much of each; they must add up to the line's amount. They may
carry a discount for a cash discount (Skonto) taken, and one line can settle several documents
of the same party. The call records a payment dated at the booking date, which goes through the
approval policy like any payment: status is posted, or pending_approval when a second
person has to approve it first. The line becomes matched.
curl -X POST "$ERP_URL/v1/bank-lines/$LINE_CREDIT/match" \
-H "Authorization: Bearer $ERP_KEY" \
-H "Content-Type: application/json" \
-d '{"allocations": [{"document": "$CANDIDATE_DOCUMENT", "amount": "1081.00"}]}'
{
"payment_id": "<uuid>",
"status": "posted",
"discount": "0.00",
"submit": {"status": "posted", "policy": {"decision": "allow"}}
}
The invoice is settled: its open amount is now 0.00 (GET /v1/documents/{id}).
5. Post a bank fee
A fee is not a payment of any invoice: nothing is open for it, so the candidates are empty.
curl "$ERP_URL/v1/bank-lines/$LINE_FEE/candidates" \
-H "Authorization: Bearer $ERP_KEY"
[]
POST /v1/bank-lines/{id}/post books the line directly against an account: a two-line journal
entry, dated at the booking date, with the bank account on one side and account on the other
(here 6940, bank charges), submitted through the approval policy. The line becomes matched, and
becomes unmatched again if the entry is rejected.
curl -X POST "$ERP_URL/v1/bank-lines/$LINE_FEE/post" \
-H "Authorization: Bearer $ERP_KEY" \
-H "Content-Type: application/json" \
-d '{"account": "6940", "description": "Bank charges September"}'
{"entry_id": "<uuid>", "status": "posted", "policy": {"decision": "allow"}}
6. Ignore a line, and reopen it
The 2000.00 is a transfer to the company's own savings account, which is booked elsewhere. Nothing
should be posted for it, so the line is ignored with a reason. That needs the post scope. An
ignored line no longer counts as unmatched, but it is not lost: the reason is kept on the line.
curl -X POST "$ERP_URL/v1/bank-lines/$LINE_TRANSFER/ignore" \
-H "Authorization: Bearer $ERP_KEY" \
-H "Content-Type: application/json" \
-d '{"reason": "own transfer to the savings account, booked there"}'
{
"booking_date": "2026-09-29",
"amount": "-2000.00",
"status": "ignored",
"match": {"kind": "ignored", "reason": "own transfer to the savings account, booked there"}
}
If that was a mistake, POST /v1/bank-lines/{id}/reopen puts the line back to unmatched. Only
ignored lines can be reopened; a matched line is undone by rejecting or reversing what it created.
curl -X POST "$ERP_URL/v1/bank-lines/$LINE_TRANSFER/reopen" \
-H "Authorization: Bearer $ERP_KEY"
{"amount": "-2000.00", "status": "unmatched", "match": {"reopened": true}}
7. Check the result
The statement list shows each statement with its opening and closing balance as the bank reported them, and how many of its lines are unmatched, matched and ignored. The reopened transfer is the one line still to do.
curl "$ERP_URL/v1/entities/CH01/bank-statements" \
-H "Authorization: Bearer $ERP_KEY"
[
{
"statement_ref": "DEMO-2026-09",
"account": "1020",
"currency": "CHF",
"format": "camt053",
"opening_balance": "111639.80",
"closing_balance": "110155.30",
"lines": 4,
"unmatched": 1,
"matched": 3,
"ignored": 0
},
{"statement_ref": "DEMO-2026-08", "lines": 4, "unmatched": 0, "ignored": 0}
]
The matched lines say how each was settled in match: method: rule for the matcher, with the
reasons it gave; manual (or agent when an agent key did it) for a person; kind: payment for
a payment against documents, kind: journal for a direct posting.
curl "$ERP_URL/v1/entities/CH01/bank-lines?status=matched&statement=$STATEMENT_ID" \
-H "Authorization: Bearer $ERP_KEY"
[
{
"booking_date": "2026-09-14",
"amount": "1081.00",
"status": "matched",
"match": {"kind": "payment", "method": "manual", "status": "posted"}
},
{
"booking_date": "2026-09-15",
"amount": "-540.50",
"status": "matched",
"match": {
"kind": "payment",
"method": "rule",
"reasons": ["reference contains BM-5002", "exact open amount"],
"status": "posted"
}
},
{
"booking_date": "2026-09-28",
"amount": "-25.00",
"status": "matched",
"match": {"kind": "journal", "method": "manual", "status": "posted"}
}
]
8. Reconcile the account and mark it done
The reconciliation of a bank account on a statement date sets the bank's closing balance against
the account's balance in the ledger and lists what explains the difference: statement lines the
ledger does not show by that date (bank_items: unmatched, ignored, booked later or waiting for
approval) and posted entries no statement line shows by then (ledger_items, such as a payment
the bank has not executed yet). unexplained is what the items do not explain; it is zero when
the account is reconciled. The reopened transfer is the one item left.
curl "$ERP_URL/v1/entities/CH01/bank-reconciliation?account=1020&on=2026-09-30" \
-H "Authorization: Bearer $ERP_KEY"
{
"account": "1020",
"on": "2026-09-30",
"statement_ref": "DEMO-2026-09",
"statement_balance": "110155.30",
"ledger_balance": "112155.30",
"difference": "-2000.00",
"bank_items": [
{
"booking_date": "2026-09-29",
"amount": "-2000.00",
"text": "Umbuchung Sparkonto",
"reason": "unmatched"
}
],
"bank_items_total": "-2000.00",
"ledger_items": [],
"unexplained": "0.00",
"done": null
}
A person then marks the date reconciled. The record keeps who did it, when, and the figures as
they are now; it is never changed. Part of the difference left unexplained needs a note saying
why (reconciliation_unexplained otherwise); a CSV statement carries no closing balance, so the
bank's balance is given as statement_balance.
curl -X POST "$ERP_URL/v1/entities/CH01/bank-reconciliations" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Content-Type: application/json" \
-d '{"account": "1020", "on": "2026-09-30"}'
{
"account": "1020",
"statement_date": "2026-09-30",
"statement_balance": "110155.30",
"ledger_balance": "112155.30",
"unexplained": "0.00",
"done_by": {"type": "human", "name": "alice's key"},
"done_at": "<date-time>"
}
9. A CSV statement
Banks that do not deliver camt.053 often offer a CSV export. The same call takes format=csv and a
csv_mapping: a JSON object, sent as a string in the form, that names the columns. Its keys are
date, amount, currency, counterparty, iban, reference, description, bank_reference,
date_format (a strptime pattern, default %Y-%m-%d), decimal_comma, delimiter and
default_currency (for lines without a currency; default: the bank account's currency). Without a mapping the columns are named date, amount,
counterparty, reference and description. The file below uses a semicolon, German date
format and a decimal comma:
Buchungsdatum;Betrag;Gegenpartei;Verwendungszweck
29.09.2026;-18,00;Kartenzentrum;Kartengebuehr September
30.09.2026;-12,50;;Porto September
A CSV file has no statement id and no account, so the statement is identified by a hash of the
file and the lines go to the entity's default bank account (or the one named in account). Map
bank_reference to a unique transaction column if the file has one: only then can the same
transaction be recognized when a later file overlaps.
curl -X POST "$ERP_URL/v1/entities/CH01/bank-statements" \
-H "Authorization: Bearer $ERP_KEY" \
-F "format=csv" \
-F 'csv_mapping={"date": "Buchungsdatum", "amount": "Betrag", "counterparty": "Gegenpartei", "description": "Verwendungszweck", "date_format": "%d.%m.%Y", "decimal_comma": true, "delimiter": ";"}' \
-F "file=@files/card-fees-2026-09.csv"
[{"statement_ref": "<any>", "imported": 2, "duplicates": 0, "matched": 0, "unmatched": 2}]
Both lines are card and postage fees, which are posted like the bank fee in step 5.
What can go wrong
The same statement cannot be imported twice. The statement id (DEMO-2026-09) and the transaction
references are what make a file recognizable, and a second import is refused with 409.
curl -X POST "$ERP_URL/v1/entities/CH01/bank-statements" \
-H "Authorization: Bearer $ERP_KEY" \
-F "format=camt053" \
-F "file=@files/camt053-2026-09.xml"
{"error": {"code": "statement_imported"}}
A line that is no longer unmatched cannot be matched, posted or ignored again (409). Matching the credit a second time:
curl -X POST "$ERP_URL/v1/bank-lines/$LINE_CREDIT/match" \
-H "Authorization: Bearer $ERP_KEY" \
-H "Content-Type: application/json" \
-d '{"allocations": [{"document": "$CANDIDATE_DOCUMENT", "amount": "1081.00"}]}'
{"error": {"code": "bank_line_done"}}
The allocations must add up to the amount of the line. The transfer of 2000.00 is not paid by the bill of 540.50 (422, nothing is recorded):
curl -X POST "$ERP_URL/v1/bank-lines/$LINE_TRANSFER/match" \
-H "Authorization: Bearer $ERP_KEY" \
-H "Content-Type: application/json" \
-d '{"allocations": [{"document": "$BILL_ID", "amount": "540.50"}]}'
{"error": {"code": "allocations_do_not_add_up"}}
Only an ignored line can be reopened (409). The fee line was posted, so reopening it is refused:
curl -X POST "$ERP_URL/v1/bank-lines/$LINE_FEE/reopen" \
-H "Authorization: Bearer $ERP_KEY"
{"error": {"code": "bank_line_not_ignored"}}
Reference
POST /v1/entities/{entity}/bank-statementsGET /v1/entities/{entity}/bank-statementsGET /v1/entities/{entity}/bank-linesGET /v1/bank-lines/{line_id}/candidatesPOST /v1/bank-lines/{line_id}/matchPOST /v1/bank-lines/{line_id}/postPOST /v1/bank-lines/{line_id}/ignorePOST /v1/bank-lines/{line_id}/reopenGET /v1/entities/{entity}/bank-reconciliationPOST /v1/entities/{entity}/bank-reconciliationsPOST /v1/documents