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.

Access is provisioned by AI11.

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.

payment.json · synthetic example
{
  "scheme": "epc_qr",
  "language": "de",
  "creditor": {
    "name": "Muster GmbH",
    "iban": "AT611904300234573201",
    "bic": "BKAUATWW"
  },
  "amount": "1234.56",
  "message": "RE-2026-0042"
}
Validate payment data
# 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/validate

HTTP 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.

BASH
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 SCOR or NON.
  • From 14 November 2026 (QR-bill v2.4), EUR bills cannot use a QR-IBAN or QRR. Use a normal IBAN with SCOR or NON. The service applies this rule already.
  • message and billing_information together 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 a message, 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:

JSON
{
  "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:

JSON
{
  "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:

BASH
curl --fail-with-body --user '<accessKey>' -H 'Content-Type: application/json' \
  --data @payment.json https://qr.service.ai11.io/v1/validate
JSON
{"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):

BASH
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:

BASH
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:

BASH
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:

BASH
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:

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.

PYTHON
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.