StabisoDocs

REST API

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: draft to import, match and post lines, post to ignore and reopen lines, read for the lists. $ERP_KEY holds them all.
  • The demo organization: CH01 (Demo AG, CHF) has the bank account 1020 registered under the IBAN CH93 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

Stabiso, the Enterprise System of Record · generated from the code and its guides · release 20b7dd5b0c4d