TALLYWORKS · XRECHNUNG API

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.

Kein Entwickler? E-Rechnung mit dem Online-Formular erstellen, ohne Programmierung. Ob Sie E-Rechnungen ausstellen müssen: E-Rechnungspflicht erklärt.
Not a developer? Use the online form (German, no code).

Machine-readable: OpenAPI 3.1 description for Postman, Insomnia or code generators.

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.

EndpointReturnsUse it for
POST /v1/zugferdapplication/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/xrechnungapplication/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.

StatusWhenBody
400The body is not valid JSON.{"error":"request body must be valid JSON"}
401The API key is unknown, expired or disabled (paid plans).{"error":"API key is invalid, expired or disabled"}
403The API key belongs to another product.{"error":"API key is not for a Tallyworks API plan"}
404Unknown path.{"error":"not found"}
405Anything other than POST on a /v1 endpoint.{"error":"use POST"}
413The body is larger than 256 KB.{"error":"request body too large"}
429The 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"}
503An 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:

FieldTypeNotes
number requiredstringInvoice number. Also used as the file name of the result.
issueDate requiredstringInvoice date, YYYY-MM-DD.
dueDatestringPayment due date, YYYY-MM-DD.
deliveryDatestringDate of supply or service. Required unless period is given.
periodobjectService period, instead of deliveryDate.
period.start requiredstringFirst day.
period.end requiredstringLast day; not before start.
currencystringISO 4217 currency code. Default EUR.
buyerReferencestringLeitweg-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.
notestringFree text printed on the invoice.
vatExemptionReasonstringRequired 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 requiredobject
seller.name requiredstringLegal or trading name.
seller.electronicAddress requiredstringEmail address where this party receives e-invoices (BT-34 / BT-49). Only email addresses are supported, no Peppol IDs yet.
seller.address requiredobject
seller.address.streetstringStreet and house number.
seller.address.city requiredstringCity.
seller.address.postalCode requiredstringPostal code.
seller.address.country requiredstringISO 3166-1 alpha-2 country code.
seller.vatIdstringVAT ID (USt-IdNr.), e.g. DE123456789. Required on both sides for reverse charge (category AE).
seller.taxNumberstringSteuernummer; needed when there is no vatId.
seller.identifierstringAny identifier the buyer knows you by (BT-29), e.g. a customer or supplier number.
seller.registrationNumberstringCommercial register number (BT-30), e.g. HRB 12345.
seller.contact requiredobjectContact person for questions about the invoice (required by XRechnung).
seller.contact.name requiredstringName.
seller.contact.phone requiredstringPhone number.
seller.contact.email requiredstringEmail address.
buyer requiredobject
buyer.name requiredstringLegal or trading name.
buyer.electronicAddress requiredstringEmail address where this party receives e-invoices (BT-34 / BT-49). Only email addresses are supported, no Peppol IDs yet.
buyer.address requiredobject
buyer.address.streetstringStreet and house number.
buyer.address.city requiredstringCity.
buyer.address.postalCode requiredstringPostal code.
buyer.address.country requiredstringISO 3166-1 alpha-2 country code.
buyer.vatIdstringVAT ID (USt-IdNr.), e.g. DE123456789. Required on both sides for reverse charge (category AE).
payment requiredobjectSEPA credit transfer details.
payment.iban requiredstringIBAN; spaces allowed, the checksum is verified.
payment.bicstringBIC.
payment.accountNamestringAccount holder, if different from the seller name.
payment.termsstringPayment terms in words, e.g. "Zahlbar innerhalb von 14 Tagen ohne Abzug."
payment.referencestringRemittance information the buyer should use.
lines[] requiredarray1 to 250 entries.
lines[].name requiredstringItem name.
lines[].descriptionstringLonger description.
lines[].quantity requirednumber or stringQuantity, up to 4 decimal places. Negative for returns or corrections.
lines[].unitstringUN/ECE Recommendation 20 unit code: C62 piece, HUR hour, DAY day, MON month, KGM kilogram. Default C62.
lines[].unitPrice requirednumber or stringNet price per unit, up to 6 decimal places, not negative.
lines[].vatRate requirednumber or stringVAT rate in percent, up to 2 decimal places, e.g. 19 or 7. 0 for categories Z, E and AE.
lines[].vatCategoryS | Z | E | AES 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