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.

What this API does not do (yet). It generates documents; it does not transmit them. There is no PEPPOL delivery, no French PA and no KSeF submission on this channel — you receive the file and send it through your own route. We would rather say so here than in a refund email.

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

formatYou getUse it for
json (default)PDF (base64) + CII XMLMost integrations
pdfThe PDF bytes, streamedPiping straight into an email attachment
zugferdSame as jsonSaying the profile out loud
xrechnungXRechnung 3.0 (CII) XML in the PDFGerman public buyers (B2G) — requires buyerReference
ublUBL syntax instead of CII, no PDFSystems that read UBL
xml-onlyXML only, no PDF renderedYou already render your own document (the Lite tier)
XRechnung needs a Leitweg-ID. BR-DE-15 makes BT-10 mandatory, and an authority's portal rejects the file without it — so send "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.

StatuserrorMeaning
401Missing, unknown or revoked API key.
400invalid_requestA field is missing or malformed — the response names it.
400seller_profile_incompleteYour issuer details are incomplete; missing lists them.
400tenant_not_provisionedThe key has no invoicing profile yet — POST /account.
402quota_exceededThis month's allowance is spent. Upgrade, or wait for the reset.
402pdf_not_includedThe Lite tier returns XML only — use xml-only/ubl or upgrade.
429Over 60 documents/minute on one key.

Pricing

TierPer monthDocumentsIncludes
Trial€025The full pipeline — real ZUGFeRD output
Lite€192,000EN 16931 XML only (CII or UBL), no PDF rendering
Starter€49500PDF + embedded XML, gap-free legal numbering
Growth€1492,500Everything in Starter plus reverse-charge handling
Scale€39910,000For 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