Developer docs
Send invoice data as JSON, get back a German e-invoice: an XRechnung XML file or a ZUGFeRD PDF. No signup and no key for up to 25 invoices a month.
Not a developer? Use the online form (German, no code).
Machine-readable: OpenAPI 3.1 description for Postman, Insomnia or code generators.
- Quickstart
- Code examples: JavaScript, PHP, Python
- Endpoints
- Responses and errors
- Request fields
- Common VAT cases
- Limits, keys and data
Quickstart
Save this as invoice.json:
{
"number": "RE-2026-0042",
"issueDate": "2026-10-11",
"dueDate": "2026-10-25",
"deliveryDate": "2026-10-09",
"buyerReference": "PO-2026-118",
"seller": {
"name": "Muster Webdesign GmbH",
"electronicAddress": "rechnung@muster.example",
"address": {
"street": "Hauptstraße 1",
"city": "Berlin",
"postalCode": "10115",
"country": "DE"
},
"vatId": "DE123456789",
"contact": {
"name": "Erika Muster",
"phone": "+49 30 1234567",
"email": "erika@muster.example"
}
},
"buyer": {
"name": "Beispiel AG",
"electronicAddress": "invoices@beispiel-ag.example",
"address": {
"street": "Industrieweg 7",
"city": "Köln",
"postalCode": "50667",
"country": "DE"
}
},
"payment": {
"iban": "DE02120300000000202051"
},
"lines": [
{
"name": "Webdesign",
"quantity": "12.5",
"unit": "HUR",
"unitPrice": "95.00",
"vatRate": 19
}
]
}
Then send it to either endpoint:
# ZUGFeRD: a PDF with the invoice data embedded, for business customers
curl https://xrechnung.tallyworks.dev/v1/zugferd \
-H "content-type: application/json" \
--fail-with-body -o RE-2026-0042.pdf \
-d @invoice.json
# XRechnung: XML, for public-sector portals
curl https://xrechnung.tallyworks.dev/v1/xrechnung \
-H "content-type: application/json" \
--fail-with-body -o RE-2026-0042.xml \
-d @invoice.json
Want to see the result first? Example PDF · example XML.
Code examples
Each example creates the PDF above, saves it and prints how many free invoices are left. On a mistake it prints
every problem with the field to fix. Change /v1/zugferd to /v1/xrechnung and the file
extension to .xml for XRechnung.
JavaScript
// Node.js 18 or later. No packages needed.
import { writeFile } from "node:fs/promises";
const invoice = {
number: "RE-2026-0042",
issueDate: "2026-10-11",
dueDate: "2026-10-25",
deliveryDate: "2026-10-09",
buyerReference: "PO-2026-118",
seller: {
name: "Muster Webdesign GmbH",
electronicAddress: "rechnung@muster.example",
address: {
street: "Hauptstraße 1",
city: "Berlin",
postalCode: "10115",
country: "DE"
},
vatId: "DE123456789",
contact: {
name: "Erika Muster",
phone: "+49 30 1234567",
email: "erika@muster.example"
}
},
buyer: {
name: "Beispiel AG",
electronicAddress: "invoices@beispiel-ag.example",
address: {
street: "Industrieweg 7",
city: "Köln",
postalCode: "50667",
country: "DE"
}
},
payment: {
iban: "DE02120300000000202051"
},
lines: [
{
name: "Webdesign",
quantity: "12.5",
unit: "HUR",
unitPrice: "95.00",
vatRate: 19
}
]
};
const res = await fetch("https://xrechnung.tallyworks.dev/v1/zugferd", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(invoice),
});
if (!res.ok) {
const { error, details = [] } = await res.json();
console.error(`${res.status} ${error}`);
for (const d of details) console.error(` ${d.path}: ${d.message}`);
process.exit(1);
}
await writeFile(`${invoice.number}.pdf`, Buffer.from(await res.arrayBuffer()));
console.log(`Saved ${invoice.number}.pdf, ${res.headers.get("x-usage-remaining")} free invoices left this month`);
Run with node invoice.mjs (save as invoice.mjs).
PHP
<?php
// PHP 7.4 or later with the curl extension. No packages needed.
$invoice = [
'number' => 'RE-2026-0042',
'issueDate' => '2026-10-11',
'dueDate' => '2026-10-25',
'deliveryDate' => '2026-10-09',
'buyerReference' => 'PO-2026-118',
'seller' => [
'name' => 'Muster Webdesign GmbH',
'electronicAddress' => 'rechnung@muster.example',
'address' => [
'street' => 'Hauptstraße 1',
'city' => 'Berlin',
'postalCode' => '10115',
'country' => 'DE',
],
'vatId' => 'DE123456789',
'contact' => [
'name' => 'Erika Muster',
'phone' => '+49 30 1234567',
'email' => 'erika@muster.example',
],
],
'buyer' => [
'name' => 'Beispiel AG',
'electronicAddress' => 'invoices@beispiel-ag.example',
'address' => [
'street' => 'Industrieweg 7',
'city' => 'Köln',
'postalCode' => '50667',
'country' => 'DE',
],
],
'payment' => [
'iban' => 'DE02120300000000202051',
],
'lines' => [
[
'name' => 'Webdesign',
'quantity' => '12.5',
'unit' => 'HUR',
'unitPrice' => '95.00',
'vatRate' => 19,
],
],
];
$headers = [];
$ch = curl_init('https://xrechnung.tallyworks.dev/v1/zugferd');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($invoice, JSON_UNESCAPED_UNICODE),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$headers) {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return strlen($line);
},
]);
$body = curl_exec($ch);
if ($body === false) {
fwrite(STDERR, curl_error($ch) . "\n");
exit(1);
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status !== 200) {
$problem = json_decode($body, true);
fwrite(STDERR, "$status {$problem['error']}\n");
foreach ($problem['details'] ?? [] as $d) {
fwrite(STDERR, " {$d['path']}: {$d['message']}\n");
}
exit(1);
}
file_put_contents("{$invoice['number']}.pdf", $body);
echo "Saved {$invoice['number']}.pdf, {$headers['x-usage-remaining']} free invoices left this month\n";
Run with php invoice.php (save as invoice.php).
Python
# Python 3.8 or later with requests (pip install requests).
import sys
import requests
invoice = {
"number": "RE-2026-0042",
"issueDate": "2026-10-11",
"dueDate": "2026-10-25",
"deliveryDate": "2026-10-09",
"buyerReference": "PO-2026-118",
"seller": {
"name": "Muster Webdesign GmbH",
"electronicAddress": "rechnung@muster.example",
"address": {
"street": "Hauptstraße 1",
"city": "Berlin",
"postalCode": "10115",
"country": "DE",
},
"vatId": "DE123456789",
"contact": {
"name": "Erika Muster",
"phone": "+49 30 1234567",
"email": "erika@muster.example",
},
},
"buyer": {
"name": "Beispiel AG",
"electronicAddress": "invoices@beispiel-ag.example",
"address": {
"street": "Industrieweg 7",
"city": "Köln",
"postalCode": "50667",
"country": "DE",
},
},
"payment": {
"iban": "DE02120300000000202051",
},
"lines": [
{
"name": "Webdesign",
"quantity": "12.5",
"unit": "HUR",
"unitPrice": "95.00",
"vatRate": 19,
},
],
}
res = requests.post("https://xrechnung.tallyworks.dev/v1/zugferd", json=invoice, timeout=30)
if not res.ok:
problem = res.json()
print(res.status_code, problem["error"], file=sys.stderr)
for d in problem.get("details", []):
print(f" {d['path']}: {d['message']}", file=sys.stderr)
sys.exit(1)
with open(f"{invoice['number']}.pdf", "wb") as f:
f.write(res.content)
print(f"Saved {invoice['number']}.pdf, {res.headers['x-usage-remaining']} free invoices left this month")
Run with python3 invoice.py (save as invoice.py).
Amounts and quantities can be numbers or strings. Strings such as "95.00" avoid
floating-point surprises in your own code; the API calculates totals with exact decimal arithmetic either way.
Endpoints
Base URL https://xrechnung.tallyworks.dev. Both endpoints take the same JSON.
| Endpoint | Returns | Use it for |
|---|---|---|
POST /v1/zugferd | application/pdf: ZUGFeRD 2 / Factur-X, profile EN 16931, PDF/A-3 with the CII XML embedded |
Business customers. People can read the PDF; accounting software reads the embedded data. buyerReference optional. |
POST /v1/xrechnung | application/xml: XRechnung 3.0 in UBL 2.1 syntax |
Public-sector buyers and portals such as ZRE and OZG-RE. buyerReference required (the Leitweg-ID). |
GET /health | {"ok": true} | Uptime monitoring. |
Every release is checked against the UBL 2.1 schema, the EN 16931 rules and the KoSIT XRechnung rules; the PDFs also with veraPDF and the Mustang validator.
Responses and errors
Success: 200
The file itself, as an attachment named after the invoice number. The headers say how much of the monthly quota is left (also on 400, 422 and 429 errors):
HTTP/1.1 200 OK
content-type: application/xml; charset=utf-8
content-disposition: attachment; filename="RE-2026-0042.xml"
x-plan: free
x-usage-limit: 25
x-usage-remaining: 24
<?xml version="1.0" encoding="UTF-8"?>
<ubl:Invoice xmlns:ubl="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0</cbc:CustomizationID>
<cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>
<cbc:ID>RE-2026-0042</cbc:ID>
<cbc:IssueDate>2026-10-11</cbc:IssueDate>
<cbc:DueDate>2026-10-25</cbc:DueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
<cbc:BuyerReference>PO-2026-118</cbc:BuyerReference>
<cac:AccountingSupplierParty>
<cac:Party>
…
For /v1/zugferd the content type is application/pdf and the file ends in .pdf.
Invalid invoice: 422
All problems are reported at once. path points to the field in your JSON. This is the response for the
quickstart invoice without deliveryDate and with a typo in the IBAN:
HTTP/1.1 422 Unprocessable Entity
content-type: application/json; charset=utf-8
{
"error": "invalid invoice",
"details": [
{
"path": "deliveryDate",
"message": "is required unless period is given (date of supply, § 14 UStG)"
},
{
"path": "payment.iban",
"message": "is not a valid IBAN"
}
]
}
Failed requests do not count against your quota.
Other errors
Always JSON with an error message in English.
| Status | When | Body |
|---|---|---|
| 400 | The body is not valid JSON. | {"error":"request body must be valid JSON"} |
| 401 | The API key is unknown, expired or disabled (paid plans). | {"error":"API key is invalid, expired or disabled"} |
| 403 | The API key belongs to another product. | {"error":"API key is not for a Tallyworks API plan"} |
| 404 | Unknown path. | {"error":"not found"} |
| 405 | Anything other than POST on a /v1 endpoint. | {"error":"use POST"} |
| 413 | The body is larger than 256 KB. | {"error":"request body too large"} |
| 429 | The monthly quota is used up. Resets on the 1st of the month (UTC). | {"error":"free limit of 25 invoices per month reached; paid plans are coming soon at https://xrechnung.tallyworks.dev"} |
| 503 | An API key was sent but paid plans are not open yet, or the key could not be checked. Retry later or leave the key out. | {"error":"paid plans are not available yet"} |
Request fields
required fields must always be there. Some fields depend on each other:
deliveryDateorperiod: the date of supply is mandatory on German invoices (§ 14 UStG).- Seller:
vatIdortaxNumber; andvatId,identifierorregistrationNumberso the buyer can identify you. buyerReference: required for/v1/xrechnung.- Category
AE(reverse charge) needsvatIdfor seller and buyer; categoryEneedsvatExemptionReason.
| Field | Type | Notes |
|---|---|---|
number required | string | Invoice number. Also used as the file name of the result. |
issueDate required | string | Invoice date, YYYY-MM-DD. |
dueDate | string | Payment due date, YYYY-MM-DD. |
deliveryDate | string | Date of supply or service. Required unless period is given. |
period | object | Service period, instead of deliveryDate. |
period.start required | string | First day. |
period.end required | string | Last day; not before start. |
currency | string | ISO 4217 currency code. Default EUR. |
buyerReference | string | Leitweg-ID for public-sector buyers, otherwise any reference the buyer gave you, such as a purchase order number. Required for /v1/xrechnung, optional for /v1/zugferd. |
note | string | Free text printed on the invoice. |
vatExemptionReason | string | Required when any line has category E, e.g. "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet." For category AE it defaults to "Steuerschuldnerschaft des Leistungsempfängers". |
seller required | object | |
seller.name required | string | Legal or trading name. |
seller.electronicAddress required | string | Email address where this party receives e-invoices (BT-34 / BT-49). Only email addresses are supported, no Peppol IDs yet. |
seller.address required | object | |
seller.address.street | string | Street and house number. |
seller.address.city required | string | City. |
seller.address.postalCode required | string | Postal code. |
seller.address.country required | string | ISO 3166-1 alpha-2 country code. |
seller.vatId | string | VAT ID (USt-IdNr.), e.g. DE123456789. Required on both sides for reverse charge (category AE). |
seller.taxNumber | string | Steuernummer; needed when there is no vatId. |
seller.identifier | string | Any identifier the buyer knows you by (BT-29), e.g. a customer or supplier number. |
seller.registrationNumber | string | Commercial register number (BT-30), e.g. HRB 12345. |
seller.contact required | object | Contact person for questions about the invoice (required by XRechnung). |
seller.contact.name required | string | Name. |
seller.contact.phone required | string | Phone number. |
seller.contact.email required | string | Email address. |
buyer required | object | |
buyer.name required | string | Legal or trading name. |
buyer.electronicAddress required | string | Email address where this party receives e-invoices (BT-34 / BT-49). Only email addresses are supported, no Peppol IDs yet. |
buyer.address required | object | |
buyer.address.street | string | Street and house number. |
buyer.address.city required | string | City. |
buyer.address.postalCode required | string | Postal code. |
buyer.address.country required | string | ISO 3166-1 alpha-2 country code. |
buyer.vatId | string | VAT ID (USt-IdNr.), e.g. DE123456789. Required on both sides for reverse charge (category AE). |
payment required | object | SEPA credit transfer details. |
payment.iban required | string | IBAN; spaces allowed, the checksum is verified. |
payment.bic | string | BIC. |
payment.accountName | string | Account holder, if different from the seller name. |
payment.terms | string | Payment terms in words, e.g. "Zahlbar innerhalb von 14 Tagen ohne Abzug." |
payment.reference | string | Remittance information the buyer should use. |
lines[] required | array | 1 to 250 entries. |
lines[].name required | string | Item name. |
lines[].description | string | Longer description. |
lines[].quantity required | number or string | Quantity, up to 4 decimal places. Negative for returns or corrections. |
lines[].unit | string | UN/ECE Recommendation 20 unit code: C62 piece, HUR hour, DAY day, MON month, KGM kilogram. Default C62. |
lines[].unitPrice required | number or string | Net price per unit, up to 6 decimal places, not negative. |
lines[].vatRate required | number or string | VAT rate in percent, up to 2 decimal places, e.g. 19 or 7. 0 for categories Z, E and AE. |
lines[].vatCategory | S | Z | E | AE | S standard rate (default when vatRate > 0), Z zero-rated, E exempt (e.g. § 19 UStG), AE reverse charge. Required when vatRate is 0. |
Common VAT cases
Each case shows only what changes in the quickstart invoice: the fields shown replace the ones there
(lines replaces the whole list), everything else stays.
Small business exemption (Kleinunternehmer, § 19 UStG)
No VAT ID needed: instead of seller.vatId, send your Steuernummer plus an identifier the buyer knows you by. Remove seller.vatId.
{
"vatExemptionReason": "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.",
"seller": {
"taxNumber": "231/123/45678",
"identifier": "MM-FOTO-LEIPZIG"
},
"lines": [
{
"name": "Fotoshooting",
"quantity": 1,
"unitPrice": "450.00",
"vatRate": 0,
"vatCategory": "E"
}
]
}
EU reverse charge
Both parties need a VAT ID. The exemption note is added automatically.
{
"seller": {
"vatId": "DE987654321"
},
"buyer": {
"vatId": "NL123456789B01",
"address": {
"city": "Amsterdam",
"postalCode": "1017 GC",
"country": "NL"
}
},
"lines": [
{
"name": "Softwareentwicklung",
"quantity": 40,
"unit": "HUR",
"unitPrice": "110.00",
"vatRate": 0,
"vatCategory": "AE"
}
]
}
Monthly service with two VAT rates
Send period instead of deliveryDate. Each VAT rate gets its own subtotal. Remove deliveryDate.
{
"period": {
"start": "2026-09-01",
"end": "2026-09-30"
},
"lines": [
{
"name": "Wartungsvertrag September",
"quantity": 1,
"unit": "MON",
"unitPrice": "300.00",
"vatRate": 19
},
{
"name": "Fachbuch",
"quantity": 2,
"unitPrice": "24.30",
"vatRate": 7
}
]
}
Correction or return
A line with a negative quantity reduces the total. Prices stay positive.
{
"lines": [
{
"name": "Fachbuch",
"quantity": 3,
"unitPrice": "24.30",
"vatRate": 7
},
{
"name": "Rückgabe Fachbuch",
"quantity": -1,
"unitPrice": "24.30",
"vatRate": 7
}
]
}
Limits, keys and data
- Free: 25 invoices per month per IP address, no key. Only successful conversions count. The count resets on the 1st of each month (UTC).
- Paid plans (coming soon): Starter €19 for 2,500 and Pro €39 for
15,000 invoices a month. You will send the key as
Authorization: Bearer <key>orx-api-key: <key>. Until then, leave the key out. Need more now? Write to tallyworks.ops@gmail.com. - Stability:
/v1only gets backwards-compatible changes (new optional fields). Anything breaking would come as/v2, with/v1kept running. - Retries: on 503 or a network error, retry after a few seconds. Failed requests are never counted, so retrying is safe.
- Size: up to 250 lines and 256 KB per invoice.
- Your data: invoices are converted in memory and not stored or logged. See Privacy.
- Not tax advice: the API makes sure the e-invoice is correctly structured. You stay responsible for its content, such as the right VAT treatment.