E-Invoicing API
Post an order, get back the finished document: a branded PDF with the EN 16931 XML embedded (ZUGFeRD / Factur-X, PDF/A-3), the XML on its own, and a gap-free legal invoice number allocated from your own sequence.
The same pipeline runs behind our Shopify and Shopware apps, and every XML sample it produces is checked against Mustang and the official KoSIT XRechnung scenario in our CI — a file that fails there does not ship.
1. Get an account and a key
Sign in at saypdf.com/dashboard, then:
# provision your invoicing tenant (idempotent — safe to repeat)
curl -X POST "https://api.saypdf.com/api/invoicing/v1/account" \
-H "Authorization: Bearer $SITE_JWT"
# mint an API key — the secret is returned ONCE and never stored in readable form
curl -X POST "https://api.saypdf.com/api/invoicing/v1/keys" \
-H "Authorization: Bearer $SITE_JWT" \
-H "Content-Type: application/json" \
-d '{ "label": "production" }'
2. Fill in your seller profile
An invoice without the issuer's name, address and tax number is not a valid invoice
(§ 14 Abs. 4 UStG and its equivalents), so the API refuses to issue one until these exist —
with 400 seller_profile_incomplete listing exactly what is missing.
curl -X POST "https://api.saypdf.com/api/invoicing/v1/settings" \
-H "Authorization: Bearer $SITE_JWT" -H "Content-Type: application/json" \
-d '{ "companyName": "Muster GmbH", "companyAddress1": "Torstr. 1", "companyZip": "10119",
"companyCity": "Berlin", "companyCountry": "DE", "companyVatId": "DE812345678",
"companyTaxId": "30/123/45678" }'
Prefer a form? GET /api/invoicing/v1/settings/link returns a short-lived URL you can open in a browser — the form itself carries its own signed token, because a browser navigation sends no Authorization header.
3. Issue a document
curl -X POST "https://api.saypdf.com/api/invoicing/v1/invoices" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"reference": "ORD-1001",
"currency": "EUR",
"pricesIncludeTax": false,
"lineItems": [
{ "description": "Consulting, October", "quantity": 2, "unitPrice": "100.00",
"taxes": [{ "ratePercent": 19 }] }
],
"buyer": {
"email": "einkauf@kunde.de",
"vatId": "DE123456789",
"billingAddress": { "company": "Kunde GmbH", "line1": "Hauptstr. 1",
"postalCode": "80331", "city": "München", "country": "DE" }
}
}'
{
"idempotent": false,
"number": "RE-2026-0042",
"profile": "en16931",
"filename": "Invoice-RE-2026-0042.pdf",
"pdfBase64": "JVBERi0xLjQ...",
"xml": "<rsm:CrossIndustryInvoice>...</rsm:CrossIndustryInvoice>",
"warning": null
}
reference is your idempotency key
reference is required, and it is the field that makes a retry safe. Calling twice with
the same reference returns the same invoice number and does not consume quota
twice — the response says "idempotent": true. Without that rule, a network
timeout would issue two legally distinct invoices for one sale, and a gap-free sequence with a
duplicate in it is worse than a missing document.
Output formats
format | You get | Use it for |
|---|---|---|
json (default) | PDF (base64) + CII XML | Most integrations |
pdf | The PDF bytes, streamed | Piping straight into an email attachment |
zugferd | Same as json | Saying the profile out loud |
xrechnung | XRechnung 3.0 (CII) XML in the PDF | German public buyers (B2G) — requires buyerReference |
ubl | UBL syntax instead of CII, no PDF | Systems that read UBL |
xml-only | XML only, no PDF rendered | You already render your own document (the Lite tier) |
"buyerReference": "04011000-1234512345-06".
The API refuses the request rather than handing you a document that will bounce.Quota and errors
Every successful call returns X-Quota-Limit and X-Quota-Remaining.
GET /api/invoicing/v1/usage reports the same numbers without issuing anything.
| Status | error | Meaning |
|---|---|---|
| 401 | — | Missing, unknown or revoked API key. |
| 400 | invalid_request | A field is missing or malformed — the response names it. |
| 400 | seller_profile_incomplete | Your issuer details are incomplete; missing lists them. |
| 400 | tenant_not_provisioned | The key has no invoicing profile yet — POST /account. |
| 402 | quota_exceeded | This month's allowance is spent. Upgrade, or wait for the reset. |
| 402 | pdf_not_included | The Lite tier returns XML only — use xml-only/ubl or upgrade. |
| 429 | — | Over 60 documents/minute on one key. |
Pricing
| Tier | Per month | Documents | Includes |
|---|---|---|---|
| Trial | €0 | 25 | The full pipeline — real ZUGFeRD output |
| Lite | €19 | 2,000 | EN 16931 XML only (CII or UBL), no PDF rendering |
| Starter | €49 | 500 | PDF + embedded XML, gap-free legal numbering |
| Growth | €149 | 2,500 | Everything in Starter plus reverse-charge handling |
| Scale | €399 | 10,000 | For platforms issuing on behalf of their own customers |
# start a checkout (returns a Stripe URL)
curl -X POST "https://api.saypdf.com/api/invoicing/v1/checkout" \
-H "Authorization: Bearer $SITE_JWT" -H "Content-Type: application/json" \
-d '{ "tier": "api_starter" }'
Node.js
const res = await fetch("https://api.saypdf.com/api/invoicing/v1/invoices", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.SAYPDF_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
reference: order.id, // idempotency key — retry safely
currency: "EUR",
pricesIncludeTax: false,
lineItems: order.items.map((i) => ({
description: i.name, quantity: i.qty, unitPrice: i.net, taxes: [{ ratePercent: i.vat }],
})),
buyer: { email: order.email, vatId: order.vatId, billingAddress: order.address },
}),
});
const { number, pdfBase64, xml } = await res.json();
Python
import requests, base64
r = requests.post(
"https://api.saypdf.com/api/invoicing/v1/invoices",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"reference": order_id, "currency": "EUR", "pricesIncludeTax": False,
"lineItems": [{"description": "Consulting", "quantity": 1,
"unitPrice": "100.00", "taxes": [{"ratePercent": 19}]}]},
)
doc = r.json()
open(f"{doc['number']}.pdf", "wb").write(base64.b64decode(doc["pdfBase64"]))
open(f"{doc['number']}.xml", "w").write(doc["xml"])
Notes
- Numbers come from your sequence: gap-free, optionally year-resetting, with your own prefix.
- Credit notes get their own number from the same sequence, with the BG-3 reference to the invoice they correct.
- Reverse charge (Art. 196) is applied automatically for cross-border EU B2B when the buyer VAT id is present and no tax was charged.
- Free tools you can use without an account: PDF → ZUGFeRD and the E-Rechnung viewer.