From a bill to a payment file
A Swiss company enters a vendor bill, lets the system propose which bills to pay and which cash discount (Skonto) to take, turns the reviewed proposal into payments, and releases them to the bank as a pain.001 payment file. Along the way the controls that protect payments show up: the approval policy, the checks on the vendor's bank details, the rule that the person who created a payment does not release it, and the rule that agents never release money. It is for accounts-payable integrations and for the finance people who run the weekly payment run.
Before you start
- Scopes:
draftto enter bills and create the payment run,readfor the proposal and the lists,postto release a file,adminto issue an API key (only for the last refusal). - Two people. Releasing money needs a person, and not the person who created the payment. The
examples therefore use three keys:
$ALICE_KEY(accounts payable, scopesread,draft,post,approve,admin) enters the bill and releases the file,$BOB_KEY(read,draft,post,approve) prepares the payment run, and$ERP_KEY, the organization's first key, only appears in a refusal. An API key counts as the person who created it: Alice and Bob are users who were invited (POST /v1/users), signed in once, and created their key with their own session (POST /v1/api-keys). In this demo that is already done.$ERP_KEYwas issued by the system when the organization was created; it belongs to no person, so it can set things up and create documents but can never approve, release a payment file or finalise VAT. - The demo organization: entity CH01 (Demo AG, CHF) with the bank account
1020registered under its IBAN, which is the debit account of the payment file. The vendorV-BM(Büro Material AG) is active and has a QR-IBAN on file; the vendorV-IT(IT Handel AG) is active but has no IBAN. September 2026 is open; the August bills are booked. One August bill ofV-IT(IT-889, the laptop) is still open and overdue.
1. Enter the bill
A bill from V-BM, dated 1 September 2026, for office supplies. Three things make it payable the
way a Swiss supplier expects:
external_numberis the vendor's own invoice number.payment_referenceis the QR reference printed on the vendor's QR-bill. The vendor's IBAN is a QR-IBAN, and a QR-IBAN can only be paid with a QR reference (the pair is validated when the bill is entered; see thepayment_ibanfield if a bill names an account other than the vendor's).cash_discount_percentandcash_discount_daysare the terms "2% within 10 days". The bill stores the last day to take the discount (cash_discount_date) and the amount (cash_discount_amount). Without these fields the vendor's own terms apply, if it has any.
The call needs an Idempotency-Key. By default the bill is also submitted, so it is posted
through the approval policy at once: the policy lets a human post it, and the vendor's
account in the ledger carries 1081.00 (1000.00 plus 8.1% VAT) from now on.
curl -X POST "$ERP_URL/v1/documents" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Idempotency-Key: payables-bill-bm-5001" \
-H "Content-Type: application/json" \
-d '{
"entity": "CH01",
"kind": "purchase_bill",
"party": "V-BM",
"document_date": "2026-09-01",
"external_number": "BM-5001",
"payment_reference": "210000000003139471430009017",
"cash_discount_percent": "2",
"cash_discount_days": 10,
"lines": [
{"description": "Büromaterial September", "unit_price": "1000", "supply_type": "goods", "account": "6500"}
]
}'
{
"document": {
"status": "posted",
"party_code": "V-BM",
"external_number": "BM-5001",
"document_date": "2026-09-01",
"due_date": "2026-10-01",
"net_total": "1000.00",
"tax_total": "81.00",
"total": "1081.00",
"open_amount": "1081.00",
"payment_reference": "210000000003139471430009017",
"cash_discount_percent": "2",
"cash_discount_date": "2026-09-11",
"cash_discount_amount": "21.62"
},
"submit": {"status": "posted", "policy": {"decision": "allow"}}
}
Notice due_date (30 days, the vendor's default terms) and cash_discount_date (10 days). Paying
by 11 September saves 21.62.
2. Ask for a payment proposal
POST /v1/entities/CH01/payment-proposals suggests what to pay on a given day. It needs only the
read scope and stores nothing: you can ask as often as you like and change the criteria.
payment_dateis the day the money leaves the bank.due_untilis the horizon: bills due on or before it are proposed (default: the payment date).take_discounts(defaulttrue) also proposes bills that are not yet due when their cash discount would expire bydue_until, and deducts the discount from the amount.vendorslimits the proposal to some vendor codes. Here it keeps the page to the two vendors we care about.
Each line says why it is proposed in reason: overdue (due before the payment date), due
(due by the horizon) or cash_discount (not yet due, proposed to catch the discount). warnings
lists what a reviewer should look at before paying.
curl -X POST "$ERP_URL/v1/entities/CH01/payment-proposals" \
-H "Authorization: Bearer $BOB_KEY" \
-H "Content-Type: application/json" \
-d '{"payment_date": "2026-09-10", "due_until": "2026-09-15", "take_discounts": true, "vendors": ["V-BM", "V-IT"]}'
{
"entity": "CH01",
"payment_date": "2026-09-10",
"due_until": "2026-09-15",
"take_discounts": true,
"lines": [
{
"vendor": "V-BM",
"external_number": "BM-5001",
"currency": "CHF",
"open": "1081.00",
"discount": "21.62",
"amount": "1059.38",
"reason": "cash_discount",
"cash_discount_date": "2026-09-11",
"payment_iban": "CH4431999123000889012",
"payment_reference": "210000000003139471430009017",
"warnings": []
},
{
"vendor": "V-IT",
"external_number": "IT-889",
"currency": "CHF",
"discount": "0.00",
"reason": "overdue",
"payment_iban": null,
"warnings": ["no IBAN for IT Handel AG"]
}
]
}
The V-BM line is proposed although the bill is not due until 1 October: its discount ends on
11 September, within the horizon, so amount is the open amount less the discount (1059.38). The
laptop bill of V-IT is overdue, and the line warns that the vendor has no IBAN, so the payment
could be created but not released. The proposal captured the bill ids and the open amount of the
second line for the next step.
3. Create the payment run
Bob, who prepares the run, edits the lines (drops some, lowers amounts) and confirms them with
POST /v1/entities/CH01/payment-runs. Each line names a bill (id or number), the amount to
pay and the discount taken. The run is all or none: if any line is refused, no payment is
created. It creates one payment per vendor, currency and payment account; a bill with a
structured reference gets a payment of its own, because a pain.001 transaction carries exactly one
reference.
Every payment goes through the approval policy like any other posting. The default policy lets a human
post at once (posted); a payment that the policy holds back, for example an agent's
payment above 5000, is pending_approval and has to be approved by a second person first. The
answer counts both. Posting reduces the bills' open amounts and books the cash out of account
1020 right away. The cash discount is booked as a correction of the bill: of the 1081.00 bill,
1059.38 leaves the bank, and 20.00 of expense and 1.62 of input VAT are reversed, so the VAT
return sees the reduced VAT. The payments are recorded as created by Bob, which matters in step 5.
curl -X POST "$ERP_URL/v1/entities/CH01/payment-runs" \
-H "Authorization: Bearer $BOB_KEY" \
-H "Idempotency-Key: payables-run-1" \
-H "Content-Type: application/json" \
-d '{
"payment_date": "2026-09-10",
"lines": [
{"document": "$BM_BILL", "amount": "1059.38", "discount": "21.62"},
{"document": "$IT_BILL", "amount": "$IT_AMOUNT"}
]
}'
{
"entity": "CH01",
"payment_date": "2026-09-10",
"posted": 2,
"pending_approval": 0,
"payments": [
{
"payment_id": "<uuid>",
"vendor": "V-BM",
"currency": "CHF",
"amount": "1059.38",
"discount": "21.62",
"status": "posted",
"approval_id": null
},
{"payment_id": "<uuid>", "vendor": "V-IT", "currency": "CHF", "status": "posted"}
]
}
The payments come back ordered by vendor code. The call captured both payment_id values.
4. List the open payments
GET /v1/payments lists outgoing payments with what is needed to release them. execution=open
means "not yet in a payment file". Each row has the payment type (D for a domestic CHF or EUR
payment to a Swiss or Liechtenstein account, S for a SEPA payment), the creditor IBAN and
reference as they will appear in the file, and problems: the reasons the payment cannot be
released now, each with a code, a plain-English message and the details it names, like an
error. An empty list means it can.
curl "$ERP_URL/v1/payments?entity=CH01&execution=open" \
-H "Authorization: Bearer $ALICE_KEY"
[
{
"party_code": "V-IT",
"status": "posted",
"execution_status": "open",
"creditor_iban": null,
"payment_type": null,
"problems": [
{"code": "party_no_iban", "message": "no IBAN for IT Handel AG", "details": {"party": "IT Handel AG"}}
]
},
{
"id": "<uuid>",
"party_code": "V-BM",
"payment_date": "2026-09-10",
"amount": "1059.38",
"currency": "CHF",
"status": "posted",
"bank_account": "1020",
"creditor_iban": "CH4431999123000889012",
"reference": "210000000003139471430009017",
"payment_type": "D",
"execution_status": "open",
"export": null,
"problems": []
},
"..."
]
Rows are newest first (by payment date, then number), so the vendor-sorted run shows up in reverse.
The V-BM payment is releasable. The V-IT payment is not: a payment is released only to an
IBAN that is on file for the vendor, and V-IT has none. The last row, left out here, is the August
payment that was recorded from the bank statement: it is already executed and has its own
problem text.
5. Release the payment as a pain.001 file
POST /v1/payment-exports (post scope, Idempotency-Key required) puts the payments you name
into one SPS pain.001.001.09 file and marks them as sent. Only payments that are posted qualify,
each only once. The file, its SHA-256 and the releasing person are stored and cannot be changed
afterwards.
Who may release is restricted (see "What can go wrong" for the refusals): the releasing caller
must be a known person, never the organization's first key; it must not be the person who
created the payment (segregation of duties, the policy action release_own_payments, denied by
default); and agents can never release. Bob created these payments, so Alice releases them.
execution_date is optional. Left out, the payment date (or today, when the payment date has
passed) is used. The bank only executes on business days, so a date that is not one is rolled
forward to the next business day of the clearing calendars of the payment's currency: for CHF
the SIC calendar (ch.six.sic), for EUR TARGET and SIC (eu.target2, ch.six.sic). For example,
25 December 2026 is a Friday and a SIC holiday, and 26 December is a Saturday, so a payment
requested for 25 December is executed on Monday 28 December. The date must not be in the past
(422), and a payment that takes a cash discount is sent for a day within the discount period, since
the vendor counts the day the money arrives (422 execution_after_cash_discount otherwise): this
run, made on 10 September, is released the same day. The record of the export lists, for each payment, the requested date, the date it is sent
for, whether it moved and the calendars used.
curl -X POST "$ERP_URL/v1/payment-exports" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Idempotency-Key: payables-export-1" \
-H "Content-Type: application/json" \
-d '{"entity": "CH01", "payments": ["$BM_PAYMENT"]}'
{
"id": "<uuid>",
"entity": "CH01",
"message_id": "<any>",
"format": "pain.001.001.09.ch.03",
"filename": "<any>",
"sha256": "<any>",
"payment_count": 1,
"control_sum": "1059.38",
"payments": ["<any>"],
"execution_dates": ["<any>"]
}
control_sum is the total the bank will check against the file. payments holds the payment
numbers, which are also the end-to-end ids in the file.
6. Read the export record and the file
The record can be read again at any time. created_by names the person or key that released it.
curl "$ERP_URL/v1/payment-exports/$EXPORT_ID" \
-H "Authorization: Bearer $ALICE_KEY"
{
"id": "<uuid>",
"entity": "CH01",
"payment_count": 1,
"control_sum": "1059.38",
"created_by": {"type": "human"}
}
GET /v1/payment-exports/{id}/file returns the file exactly as released, as an XML attachment
with its SHA-256 in the X-Content-SHA256 header. This is what you upload to e-banking. The
file's message id, creation time and execution date change from run to run, so the check below
shows the lines that do not: the namespace, the debit account (the registered IBAN of account
1020), the creditor account (the vendor's QR-IBAN), the amount and the QR reference, which the
bank needs to match the vendor's QR-bill.
curl "$ERP_URL/v1/payment-exports/$EXPORT_ID/file" \
-H "Authorization: Bearer $ALICE_KEY"
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.09">
<PmtMtd>TRF</PmtMtd>
<CtrlSum>1059.38</CtrlSum>
<IBAN>CH9300762011623852957</IBAN>
<IBAN>CH4431999123000889012</IBAN>
<InstdAmt Ccy="CHF">1059.38</InstdAmt>
<Prtry>QRR</Prtry>
<Ref>210000000003139471430009017</Ref>
The released payment now shows as sent, with the export it went out in:
curl "$ERP_URL/v1/payments?entity=CH01&execution=sent" \
-H "Authorization: Bearer $ALICE_KEY"
[
{
"party_code": "V-BM",
"execution_status": "sent",
"export": {"id": "<uuid>"},
"problems": []
}
]
What can go wrong
The person who created a payment does not release it. Bob made the run, so his release is refused
with 403 and the code policy_denied; the answer names the policy action and the rule that fired.
The remedy is a second person (Alice did it above), or, for an organization that has only one
person, an admin who allows release_own_payments in the approval policy
(PUT /v1/approval-policy, versioned and audited).
curl -X POST "$ERP_URL/v1/payment-exports" \
-H "Authorization: Bearer $BOB_KEY" \
-H "Idempotency-Key: payables-export-own" \
-H "Content-Type: application/json" \
-d '{"entity": "CH01", "payments": ["$BM_PAYMENT"]}'
{
"error": {
"code": "policy_denied",
"action": "release_own_payments",
"rule_id": "payment_creator_does_not_release"
}
}
The organization's first key, which the system issued, belongs to no person and cannot release
at all, whoever created the payment (403, forbidden):
curl -X POST "$ERP_URL/v1/payment-exports" \
-H "Authorization: Bearer $ERP_KEY" \
-H "Idempotency-Key: payables-export-system" \
-H "Content-Type: application/json" \
-d '{"entity": "CH01", "payments": ["$BM_PAYMENT"]}'
{"error": {"code": "forbidden"}}
A payment is released at most once. Putting it into another file is refused with 409, whatever
the Idempotency-Key (the same key with the same body would replay the first answer instead).
curl -X POST "$ERP_URL/v1/payment-exports" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Idempotency-Key: payables-export-2" \
-H "Content-Type: application/json" \
-d '{"entity": "CH01", "payments": ["$BM_PAYMENT"]}'
{"error": {"code": "payment_already_sent"}}
A payment with a problem cannot be released: the call is refused with 422, coded like its first problem, with all of them in the message. Here the vendor has no IBAN on file.
curl -X POST "$ERP_URL/v1/payment-exports" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Idempotency-Key: payables-export-3" \
-H "Content-Type: application/json" \
-d '{"entity": "CH01", "payments": ["$IT_PAYMENT"]}'
{"error": {"code": "party_no_iban"}}
The obvious fix is to give the vendor an IBAN. Bank details are the classic target of payment
fraud ("our bank account has changed"), so a change is never applied at once: PATCH with a new
iban creates a request that a second person must approve, the card shows it under pending,
and payments to the vendor stay blocked until it is decided. The IBAN on the card is still
empty.
curl -X PATCH "$ERP_URL/v1/entities/CH01/parties/V-IT?kind=vendor" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Content-Type: application/json" \
-d '{"iban": "CH08 0483 5098 7654 3210 0"}'
{
"code": "V-IT",
"iban": null,
"pending_actions": ["change_bank_details"],
"pending": [{"action": "change_bank_details", "proposed": {"iban": "CH0804835098765432100"}}]
}
The payment row now has a second reason. Alice made the request, so a different person decides it:
Bob, who holds the approve scope, would call POST /v1/approvals/{id}/approve with the request's
approval_id (shown under pending on the card), after which the IBAN is on the card and the
blocker is gone. The page stops here so that the refusals below still apply.
curl "$ERP_URL/v1/payments?entity=CH01&execution=open" \
-H "Authorization: Bearer $ALICE_KEY"
[
{
"party_code": "V-IT",
"problems": [
{"code": "party_no_iban", "details": {"party": "IT Handel AG"}},
{"code": "party_bank_change_waiting", "details": {"party": "IT Handel AG"}}
]
},
"..."
]
An agent key (an automation) can enter bills and create payments, but it can never release a
payment file: the approval policy reserves that for humans (403, policy_denied, naming the
rule). First issue an agent key (the secret
key is shown only once; an agent key cannot hold the approve or admin scope):
curl -X POST "$ERP_URL/v1/api-keys" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "payables bot", "actor_type": "agent", "scopes": ["read", "draft", "post"]}'
{"actor_type": "agent", "scopes": ["read", "draft", "post"]}
curl -X POST "$ERP_URL/v1/payment-exports" \
-H "Authorization: Bearer $AGENT_KEY" \
-H "Idempotency-Key: payables-export-agent" \
-H "Content-Type: application/json" \
-d '{"entity": "CH01", "payments": ["$IT_PAYMENT"]}'
{
"error": {
"code": "policy_denied",
"action": "release_payments",
"rule_id": "agents_never_approve"
}
}
Reference
POST /v1/documentsPOST /v1/entities/{entity}/payment-proposalsPOST /v1/entities/{entity}/payment-runsGET /v1/paymentsPOST /v1/payment-exportsGET /v1/payment-exports/{export_id}GET /v1/payment-exports/{export_id}/filePATCH /v1/entities/{entity}/parties/{party}POST /v1/approvals/{approval_id}/approvePOST /v1/api-keys