QR PAYMENTS API · V1
From payment data
to a payable invoice.
A practical guide to Swiss QR-bills, EPC QR codes and adding payment slips to your own PDFs.
Base URL https://qr.service.ai11.io
On this page
Your first request
Start with a synthetic EPC payment. Download the JSON, then validate it with your approved machine key. Requests are synchronous: a successful call returns the result directly.
The guide and sample files are public. API requests require a key explicitly enabled for QR payments; an OCR key does not automatically grant access. Request access.
{
"scheme": "epc_qr",
"language": "de",
"creditor": {
"name": "Muster GmbH",
"iban": "AT611904300234573201",
"bic": "BKAUATWW"
},
"amount": "1234.56",
"message": "RE-2026-0042"
}# Save the sample request locally.
curl --fail-with-body --output payment.json \
https://qr.service.ai11.io/assets/epc-payment.json
# Replace <accessKey> with your approved key.
# curl prompts for its secret; input is hidden.
curl --fail-with-body --user '<accessKey>' \
--header 'Content-Type: application/json' \
--data @payment.json \
https://qr.service.ai11.io/v1/validateHTTP 200 returns valid: true and the normalized payment, exact QR payload and its SHA-256. Read valid before generating an output. Download the complete response.
Authentication
Use a Casdoor machine key, the same kind as for the OCR service: the access key is the HTTP Basic user name, the access secret is the password. Ask the service operator for a key that is allowed for the QR service. Keep the secret in a secret store; never put it in URLs, code or logs.
curl --fail-with-body https://qr.service.ai11.io/healthz
curl --fail-with-body --user '<accessKey>' https://qr.service.ai11.io/readyz
Endpoints
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /healthz |
— | {"status":"ok"} (no credentials) |
| GET | /readyz |
— | {"status":"ready"} |
| POST | /v1/validate |
payment JSON | Issues, normalized data, payload text |
| POST | /v1/qr-codes |
payment JSON | QR code: SVG (default), PNG, PDF or payload text |
| POST | /v1/payment-slips |
payment JSON | Payment slip: PDF (default), SVG or PNG |
| POST | /v1/invoices/payment-slip |
multipart: invoice (PDF) + payment (JSON) |
Your invoice with the slip (PDF) |
| POST | /v1/references |
{"type","source"} or {"type","value"} |
Create or check a QRR/SCOR reference |
Select the output format with the Accept header: image/svg+xml, image/png,
application/pdf or (QR codes only) text/plain. Other formats return 406.
Application responses have X-Request-ID. Generated outputs also have
X-QR-Payload-SHA256, the SHA-256 of the exact QR payload. Log this value instead
of the payment data.
Payment fields & schemes
Field names use snake_case. Unknown fields are rejected (HTTP 400). Send amounts as
strings with a dot and at most two decimals: "1949.75".
| Field | ch_qr_bill |
epc_qr |
Notes |
|---|---|---|---|
scheme |
required | required | ch_qr_bill or epc_qr |
language |
de, fr, it, en |
de, en |
Slip labels; default de |
creditor.name |
required, max 70 | required, max 70 | Payee |
creditor.street |
optional, max 70 | optional | |
creditor.building_number |
optional, max 16 | optional | Requires street (CH) |
creditor.postal_code |
required, max 16 | optional | |
creditor.city |
required, max 35 | optional | |
creditor.country |
required (ISO, e.g. CH) |
optional | |
creditor.iban |
required, CH or LI | required, SEPA | Spaces allowed |
creditor.bic |
ignored (warning) | optional inside the EEA; required for other accounts (for example CH, GB) | Printed and encoded when present |
debtor |
optional, same rules as creditor address | ignored (info) | Without debtor, the Swiss slip shows a blank box |
amount |
optional, 0.01–999999999.99 | optional | Empty = the payer enters the amount |
currency |
required: CHF or EUR |
EUR (default) |
|
reference.type |
QRR, SCOR, NON |
SCOR, NON |
Default NON |
reference.value |
QRR: 27 digits; SCOR: RF… |
SCOR: RF… |
Spaces allowed |
message |
optional | optional, max 140 | CH: unstructured message |
billing_information |
optional, starts with // |
not allowed | Swico S1 billing information |
alternative_procedures |
max 2 × {"name","value"} |
not allowed | value max 100 |
purpose_code |
not allowed | optional, 4 characters | ISO 20022, e.g. GDDS |
payer_information |
not allowed | optional, max 70 | Shown in the banking app |
Swiss rules you must know
- Addresses are structured only (name, street, building number, postal code, city, country). Swiss banks reject the old combined address format.
- A QR-IBAN (institution ID 30000–31999) requires a QRR reference. A normal
IBAN uses
SCORorNON. - From 14 November 2026 (QR-bill v2.4), EUR bills cannot use a QR-IBAN or QRR.
Use a normal IBAN with
SCORorNON. The service applies this rule already. messageandbilling_informationtogether may have at most 140 characters.- Text must use the Swiss extended Latin character set (for example
ä,é,ł,ø); line breaks and emoji are not allowed.
EPC (Austria) rules you must know
- Either a creditor reference (
SCOR) or amessage, not both. - The whole QR payload must fit into 331 bytes (UTF-8). Long names, messages and
payer information together can exceed it; the service returns
payload_too_long. - Text uses the same Latin character set as Swiss bills (umlauts and
ßare fine).
Examples
Swiss QR-bill:
{
"scheme": "ch_qr_bill",
"language": "de",
"creditor": {
"name": "Robert Schneider AG", "street": "Rue du Lac", "building_number": "1268",
"postal_code": "2501", "city": "Biel", "country": "CH",
"iban": "CH44 3199 9123 0008 8901 2"
},
"debtor": {
"name": "Pia-Maria Rutschmann-Schnyder", "street": "Grosse Marktgasse", "building_number": "28",
"postal_code": "9400", "city": "Rorschach", "country": "CH"
},
"amount": "1949.75",
"currency": "CHF",
"reference": {"type": "QRR", "value": "21 00000 00003 13947 14300 09017"},
"message": "Order of 15 June 2026"
}
Austrian EPC QR code:
{
"scheme": "epc_qr",
"language": "de",
"creditor": {"name": "Muster GmbH", "iban": "AT61 1904 3002 3457 3201", "bic": "BKAUATWW"},
"amount": "1234.56",
"message": "RE-2026-0042"
}
Requests & responses
Check data first (HTTP 200 for a parsed validation request; valid tells the
result). Authentication failures or malformed requests still return HTTP errors:
curl --fail-with-body --user '<accessKey>' -H 'Content-Type: application/json' \
--data @payment.json https://qr.service.ai11.io/v1/validate
{"api_version":"1","request_id":"5ca378da7dee9031a20260ab478d63ce","valid":true,"issues":[],
"normalized":{"scheme":"epc_qr","language":"de","creditor":{"name":"Muster GmbH","iban":"AT611904300234573201","bic":"BKAUATWW"},"amount":"1234.56","currency":"EUR","reference":{"type":"NON"},"message":"RE-2026-0042"},
"payload":"BCD\n002\n1\nSCT\nBKAUATWW\nMuster GmbH\nAT611904300234573201\nEUR1234.56\n\n\nRE-2026-0042",
"payload_sha256":"484b42fa6e3bfded4cb9a155d840e6b895526c6bfd067d11abc84cad9de4bca6"}
QR code only (for your own layout):
curl --fail-with-body --user '<accessKey>' -H 'Content-Type: application/json' \
-H 'Accept: image/png' --data @payment.json -o qr.png https://qr.service.ai11.io/v1/qr-codes
- Swiss: 56 × 56 mm (46 mm code with 5 mm quiet zone). Print it at exactly this size; never scale it.
- EPC: 30 mm code plus a quiet zone of 4 modules. PNG output has 300 dpi.
Payment slip:
curl --fail-with-body --user '<accessKey>' -H 'Content-Type: application/json' \
--data @payment.json -o slip.pdf \
'https://qr.service.ai11.io/v1/payment-slips?size=slip&separation=scissors&frame=psa'
| Query option | Values | Default | Meaning |
|---|---|---|---|
size |
slip, a4 |
slip |
210 mm wide slip only, or an A4 page with the slip at the bottom |
separation |
scissors, text, none |
scissors |
CH: dashed lines with scissors, or with the text "Separate before paying in", or none (paper with perforation). EPC: a thin line above the block, or none. |
frame |
psa, none |
psa |
EPC: "Zahlen mit Code" frame |
Slip heights: Swiss 105 mm (110 mm with separator line), EPC 105 mm.
Add the slip to your invoice:
curl --fail-with-body --user '<accessKey>' \
-F 'invoice=@invoice.pdf;type=application/pdf' \
-F 'payment=<payment.json;type=application/json' \
-o invoice-with-slip.pdf -D headers.txt \
https://qr.service.ai11.io/v1/invoices/payment-slip
| Query option | Values | Default | Meaning |
|---|---|---|---|
placement |
last_page_bottom, append_page |
last_page_bottom |
Where to draw the slip |
if_no_space |
append_page, reject |
append_page |
When the last page cannot take the slip |
separation |
as above | scissors |
|
frame |
as above | psa |
|
on_signed_pdf |
reject, allow |
reject |
allow appends an incremental update; the signed revision stays unchanged, but PDF readers show that the document changed after signing |
The response header X-Payment-Slip-Placement tells you what the service did:
last_page_bottom or append_page. X-PDF-Incremental-Update: true marks an
incremental update of a signed PDF.
To get the slip on your last page, keep the bottom 110 mm of the last page empty (105 mm without separator line) and use A4 portrait. A footer in that area makes the service add a new page instead.
The service keeps embedded e-invoice data (ZUGFeRD/Factur-X XML), XMP metadata, PDF/A output intents and attachments. A PDF/A-3B invoice stays PDF/A-3B.
If the last page cannot be checked within the time and memory limits (unusually complex content), the service puts the slip on a new page.
Invoice limits: PDF only, at most 10 MiB and 50 pages, not encrypted.
Create or check a reference:
curl --fail-with-body --user '<accessKey>' -H 'Content-Type: application/json' \
-d '{"type":"SCOR","source":"RE-2026-0042"}' https://qr.service.ai11.io/v1/references
# {"type":"SCOR","valid":true,"reference":"RF97RE20260042","formatted":"RF97 RE20 2600 42"}
curl --fail-with-body --user '<accessKey>' -H 'Content-Type: application/json' \
-d '{"type":"QRR","source":"313947143000901"}' https://qr.service.ai11.io/v1/references
# {"type":"QRR","valid":true,"reference":"000000000003139471430009018","formatted":"00 00000 00003 13947 14300 09018"}
source for QRR: 1–26 digits (the customer and invoice number you choose). For
SCOR: up to 21 letters and digits; spaces, -, /, . and _ are removed.
Errors & retries
API errors use this JSON envelope. Some gateway errors (for example 404, 405
or 429) can have an HTML or empty body, so check the HTTP status and
Content-Type before parsing JSON:
{"api_version":"1","request_id":"…","error":{"code":"validation_failed","message":"The payment data is not valid.",
"issues":[{"code":"qr_iban_requires_qrr","severity":"error","field":"reference.type","message":"A QR-IBAN requires a QRR reference."}]}}
| HTTP | error.code |
Meaning |
|---|---|---|
| 400 | malformed_json, malformed_multipart, invalid_parameter |
Request format; issues names the field or part |
| 401 | unauthorized |
Missing or invalid machine key |
| 403 | forbidden |
Key not allowed for this service |
| 404 / 405 | not_found, method_not_allowed |
|
| 406 | not_acceptable |
Output format not available |
| 413 | payload_too_large, too_many_pages |
Over 10 MiB, 16 KiB JSON or 50 pages |
| 415 | unsupported_media_type, not_pdf |
Wrong content type, or invoice is not a PDF |
| 422 | validation_failed |
Payment data; see issues |
| 400 | invalid_path |
The path contains percent-encoding, ;, // or dot segments |
| 422 | pdf_encrypted, pdf_invalid, pdf_signed, no_space_on_last_page |
Invoice PDF cannot be processed as requested |
| 422 | pdf_too_complex |
The PDF needs more memory or time than allowed (for example a compression bomb) |
| 429 | — | Gateway rate limit (10 requests/s per source IP, burst 20) |
| 503 | busy |
All merge or rendering slots stayed busy for 5 s; retry after Retry-After seconds |
| 503 | authentication_unavailable |
Identity service unavailable |
| 504 | timeout |
PDF processing took longer than 25 s |
| 500 | internal_error |
Unexpected failure; report the request_id |
Issue severities: error blocks generation; warning (for example
iban_country_not_sepa, field_ignored) and info (debtor_ignored) do not.
Issue codes: body_missing, scheme_invalid, creditor_missing, name_missing,
iban_missing, iban_invalid, iban_country_not_supported,
iban_country_not_sepa, bic_invalid, bic_required, postal_code_missing, city_missing,
street_missing, country_missing, country_invalid, currency_missing,
currency_not_supported, amount_invalid, amount_out_of_range,
reference_type_missing, reference_type_invalid, reference_type_not_supported,
reference_missing, reference_invalid, reference_value_not_allowed,
eur_qr_iban_not_allowed, qr_iban_requires_qrr, qrr_requires_qr_iban,
reference_and_message_exclusive, billing_information_invalid,
additional_information_too_long, too_many_alternative_procedures,
value_missing, too_long, invalid_characters, language_not_supported,
purpose_code_invalid, payload_too_long, field_not_supported,
field_ignored, debtor_ignored, library_rejected:<key>.
Correct authentication, format and validation errors before retrying. For 429,
back off and reduce your request rate. Retry 503 busy after Retry-After.
Using OCR results
If you only have an invoice PDF, extract it with the OCR service, check the result
(especially when its status is review), build the payment JSON yourself, then
call this service. The QR service never calls the OCR service.
OCR field (RawInvoice) |
Payment JSON | Note |
|---|---|---|
supplier_name |
creditor.name |
|
supplier_address |
creditor.street, building_number, postal_code, city, country |
OCR returns one string; split it. Swiss bills need structured addresses. |
supplier_iban |
creditor.iban |
/v1/validate checks the checksum |
customer_name, customer_address |
debtor.* |
Optional (Swiss only) |
gross_total − amount_paid |
amount |
Decimal arithmetic; leave empty if unknown |
currency |
currency |
CH: CHF or EUR; EPC: EUR |
invoice_number |
reference (via /v1/references) or message |
A wrong IBAN or amount sends money to the wrong place. Have a person or a client rule confirm OCR-derived data before you create the QR code.
Python integration
Install httpx with python3 -m pip install httpx. Set QR_ACCESS_KEY and
QR_ACCESS_SECRET through your environment or secret manager, and place your
source invoice at invoice.pdf.
import json, os
import httpx
auth = (os.environ["QR_ACCESS_KEY"], os.environ["QR_ACCESS_SECRET"])
payment = {
"scheme": "epc_qr",
"creditor": {"name": "Muster GmbH", "iban": "AT61 1904 3002 3457 3201"},
"amount": "1234.56",
"message": "RE-2026-0042",
}
with httpx.Client(base_url="https://qr.service.ai11.io", auth=auth, timeout=60) as client:
validation = client.post("/v1/validate", json=payment)
validation.raise_for_status()
check = validation.json()
if not check["valid"]:
raise SystemExit(check["issues"])
with open("invoice.pdf", "rb") as invoice:
response = client.post("/v1/invoices/payment-slip", files={
"invoice": ("invoice.pdf", invoice, "application/pdf"),
"payment": ("payment.json", json.dumps(payment), "application/json"),
})
response.raise_for_status()
print("placement:", response.headers["X-Payment-Slip-Placement"])
with open("invoice-with-slip.pdf", "wb") as out:
out.write(response.content)
Going live
- Print a test invoice and scan it with your customers' banking apps (Swiss: for example PostFinance, UBS, Raiffeisen; Austrian: George, ELBA, Bank Austria, BAWAG). The service's tests decode every code automatically, but a real app scan is the final check.
- For Swiss bills, the SIX validation portal checks a complete QR-bill.