Create Invoices via API — Step by Step
This tutorial walks you through the complete invoice workflow using the docs101 REST API — from creating a customer to downloading a ZUGFeRD-compliant PDF and exporting to DATEV. By the end, you will have a working integration that creates EU-compliant invoices programmatically.
Prerequisites
- An active Pro plan with an API key (see Authentication)
curlor Python 3.8+ with therequestslibrary- Base URL:
https://docs101.com/api/v1
Python Setup
All Python examples in this tutorial use a shared setup. Define these variables once at the top of your script:
import requests
API_KEY = "ak_live_YOUR_KEY_HERE"
BASE_URL = "https://docs101.com/api/v1"
HEADERS = {
"X-API-Key": API_KEY,
"Content-Type": "application/json",
}
Step 1: Choose a Template
Every invoice needs a template that defines its visual layout. Fetch the available templates and note the id of the one you want to use.
curl -H "X-API-Key: ak_live_YOUR_KEY_HERE" \
https://docs101.com/api/v1/template/
response = requests.get(f"{BASE_URL}/template/", headers=HEADERS)
templates = response.json()
for tpl in templates:
print(f"ID: {tpl['id']} — {tpl['name']}")
You can set a default template per customer (via default_ftl_template_id) or pass the template when creating each invoice.
Step 2: Create a Customer
Create a customer record that your invoices will be billed to.
curl -X POST https://docs101.com/api/v1/customer/ \
-H "X-API-Key: ak_live_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{
"organization_name": "Acme Corp",
"email": "billing@acme.com",
"contact_type": "B2B",
"vat_id": "DE123456789",
"language": "English"
}'
customer_data = {
"organization_name": "Acme Corp",
"email": "billing@acme.com",
"contact_type": "B2B",
"vat_id": "DE123456789",
"language": "English",
}
response = requests.post(f"{BASE_URL}/customer/", headers=HEADERS, json=customer_data)
result = response.json()
customer_id = result["id"]
print(f"Created customer: {customer_id}")
Expected response (201):
{
"message": "Customer successfully created",
"id": 42
}
Customer Fields
| Field | Type | Required | Description |
|---|---|---|---|
organization_name | String | Yes (B2B) | Company name |
first_name / last_name | String | Yes (B2C) | Name for individual customers |
email | String | No | Email address |
contact_type | String | No | B2B (default) or B2C |
vat_id | String | No | EU VAT identification number |
language | String | No | Invoice language: English, German |
payment_term_id | Integer | No | Payment term ID |
default_ftl_template_id | Integer | No | Default template for this customer |
salutation | String | No | MR, MRS, MS, OTHER |
The salutation field expects uppercase values: MR, MRS, MS, OTHER. Lowercase values will return a validation error.
Step 3: Add a Billing Address
Every invoice requires a billing address on the customer.
curl -X POST https://docs101.com/api/v1/customer/42/addresses \
-H "X-API-Key: ak_live_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{
"company": "Acme Corp",
"address_line_1": "Musterstraße 1",
"postal_code": "10115",
"city": "Berlin",
"country_code": "DE",
"is_default": true
}'
address_data = {
"company": "Acme Corp",
"address_line_1": "Musterstraße 1",
"postal_code": "10115",
"city": "Berlin",
"country_code": "DE",
"is_default": True,
}
response = requests.post(
f"{BASE_URL}/customer/{customer_id}/addresses",
headers=HEADERS,
json=address_data,
)
print(f"Address created: {response.status_code}")
Address Fields
| Field | Type | Required | Description |
|---|---|---|---|
address_line_1 | String | Yes | Street and house number |
city | String | Yes | City |
country_code | String | Yes | ISO 3166-1 alpha-2 code (e.g. DE, AT, FR) |
company | String | No | Company name in the address |
address_line_2 | String | No | Additional address line |
postal_code | String | No | Postal code |
state_province | String | No | State or region |
is_default | Boolean | No | Set as default address |
Step 4: Create an Invoice
Create a draft invoice linked to your customer.
curl -X POST https://docs101.com/api/v1/invoice/ \
-H "X-API-Key: ak_live_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{
"customer_id": 42,
"benefit_period_start": "2026-04-01",
"benefit_period_end": "2026-04-30",
"invoice_format": "ZUGFERD"
}'
invoice_data = {
"customer_id": customer_id,
"benefit_period_start": "2026-04-01",
"benefit_period_end": "2026-04-30",
"invoice_format": "ZUGFERD",
}
response = requests.post(f"{BASE_URL}/invoice/", headers=HEADERS, json=invoice_data)
result = response.json()
invoice_id = result["invoice_id"]
print(f"Created invoice: {invoice_id}")
Expected response (201):
{
"message": "Invoice successfully created",
"invoice_id": 17
}
Invoice Fields
| Field | Type | Required | Description |
|---|---|---|---|
customer_id | Integer | Yes | Customer ID from Step 2 |
benefit_period_start | Date | No | Service period start (YYYY-MM-DD) |
benefit_period_end | Date | No | Service period end |
invoice_format | String | No | PDF, ZUGFERD, FACTURAPA, PEPPOL, FACTURAE |
invoice_number | String | No | Auto-generated if omitted |
meta | String | No | Free-text metadata |
The invoice_date is automatically set when the invoice is finalized (Step 7) — it is not part of the create payload.
Step 5: Add Line Items (Positions)
Add one or more line items to the invoice. Call this endpoint once per position.
curl -X POST https://docs101.com/api/v1/invoice/17/positions \
-H "X-API-Key: ak_live_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{
"title": "Pro Plan — Monthly Subscription",
"description": "April 2026",
"quantity": 1.0,
"unit_id": "HUR",
"unit_net_amount": 25.00,
"single_net_amount": 25.00,
"tax_rate": 0.19,
"tax_treatment_id": "standard"
}'
position_data = {
"title": "Pro Plan — Monthly Subscription",
"description": "April 2026",
"quantity": 1.0,
"unit_id": "HUR",
"unit_net_amount": 25.00,
"single_net_amount": 25.00,
"tax_rate": 0.19,
"tax_treatment_id": "standard",
}
response = requests.post(
f"{BASE_URL}/invoice/{invoice_id}/positions",
headers=HEADERS,
json=position_data,
)
print(f"Position added: {response.status_code}")
Position Fields
| Field | Type | Required | Description |
|---|---|---|---|
title | String | Yes | Line item description |
quantity | Float | Yes | Quantity |
unit_id | String | Yes | Unit code — e.g. HUR (hour), C62 (piece), MON (month). See Unit Codes |
unit_net_amount | Float | Yes | Net price per unit |
single_net_amount | Float | Yes | Net total for the position (quantity x unit price, before discount) |
tax_rate | Float | No | Tax rate as decimal: 0.19 = 19%, 0.07 = 7%, 0 = tax-exempt |
tax_treatment_id | String | No | e.g. standard, reverse_charge, intra_community, export_outside_eu |
description | String | No | Additional description |
discount_type | String | No | PERCENTAGE or FIXED_AMOUNT |
discount_value | Float | No | Discount value (e.g. 10 for 10% or 50 for 50 EUR) |
For cross-border B2B invoices within the EU where the customer has a valid VAT ID, use tax_treatment_id: "intra_community" with tax_rate: 0 (reverse charge). For domestic invoices (same country), use tax_treatment_id: "standard" with the applicable local tax rate. Retrieve all available tax treatments via GET /api/v1/invoice/tax-treatments?locale=en.
single_net_amount is the total net amount for the position (quantity x unit price). The server automatically recalculates invoice totals after each position is added.
Step 6: Validate the Invoice
Before finalizing, validate that all required fields are present.
curl -H "X-API-Key: ak_live_YOUR_KEY_HERE" \
https://docs101.com/api/v1/invoice/17/validate
response = requests.get(
f"{BASE_URL}/invoice/{invoice_id}/validate", headers=HEADERS
)
validation = response.json()
if validation["valid"]:
print("Invoice is valid — ready to finalize")
else:
print("Validation errors:")
for error in validation["errors"]:
print(f" - {error}")
Successful response (200):
{
"valid": true,
"errors": [],
"checks": ["..."],
"vat_check": { "status": "valid" }
}
Validation checks all fields required for PDF generation — billing address, bank details, line items, and more. A failed validation will prevent finalization in Step 7.
Step 7: Finalize — Generate PDF (Async Job)
PDF generation is an asynchronous process. You submit a job and then poll for its completion.
Start the job:
curl -X POST https://docs101.com/api/v1/invoice/17/job \
-H "X-API-Key: ak_live_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{"task": "process_invoice_flag_invoice_send_job"}'
job_response = requests.post(
f"{BASE_URL}/invoice/{invoice_id}/job",
headers=HEADERS,
json={"task": "process_invoice_flag_invoice_send_job"},
)
job_id = job_response.json()["job_id"]
print(f"Job started: {job_id}")
Expected response (201):
{
"message": "Job successfully added",
"job_id": "abc123-..."
}
Poll for completion:
curl -H "X-API-Key: ak_live_YOUR_KEY_HERE" \
https://docs101.com/api/v1/invoice/17/job/abc123-...
import time
while True:
status_response = requests.get(
f"{BASE_URL}/invoice/{invoice_id}/job/{job_id}", headers=HEADERS
)
status = status_response.json()["status"]
print(f"Job status: {status}")
if status == "finished":
break
elif status == "failed":
print(f"Job failed: {status_response.json()}")
break
time.sleep(2)
Possible status values: queued, started, finished, failed
PDF generation typically takes 3 -- 10 seconds. Implement polling with a 2-second interval and a 60-second timeout. The task name process_invoice_flag_invoice_send_job generates the PDF and transitions the invoice status from DRAFT to OPEN.
If you exceed the monthly invoice limit (3/month on Free, 100/month on Pro), the endpoint responds with 402 and a structured error containing error_code: "INVOICE_LIMIT_REACHED", usage.used, usage.limit, and usage.reset_date.
Step 8: Download the PDF
Once the job completes, retrieve the download link.
curl -H "X-API-Key: ak_live_YOUR_KEY_HERE" \
https://docs101.com/api/v1/invoice/17/pdf-link
response = requests.get(
f"{BASE_URL}/invoice/{invoice_id}/pdf-link", headers=HEADERS
)
pdf_data = response.json()
print(f"PDF URL: {pdf_data['url']}")
print(f"Filename: {pdf_data['filename']}")
Response:
{
"url": "https://...",
"filename": "INV-2026-000001.pdf"
}
The URL is a temporary (presigned) S3 URL, valid for a few minutes. To download the ZUGFeRD XML separately:
curl -H "X-API-Key: ak_live_YOUR_KEY_HERE" \
https://docs101.com/api/v1/invoice/17/xml-link
Step 9: Update Invoice Status
After sending the invoice to your customer, update its status.
Mark as sent:
curl -X PUT https://docs101.com/api/v1/invoice/17/status \
-H "X-API-Key: ak_live_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{"status": "SENT"}'
requests.put(
f"{BASE_URL}/invoice/{invoice_id}/status",
headers=HEADERS,
json={"status": "SENT"},
)
Mark as paid:
curl -X PUT https://docs101.com/api/v1/invoice/17/status \
-H "X-API-Key: ak_live_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{
"status": "PAID",
"paid_date": "2026-04-15",
"payment_method": "BANK_TRANSFER",
"payment_reference": "SEPA-2026-04-15"
}'
requests.put(
f"{BASE_URL}/invoice/{invoice_id}/status",
headers=HEADERS,
json={
"status": "PAID",
"paid_date": "2026-04-15",
"payment_method": "BANK_TRANSFER",
"payment_reference": "SEPA-2026-04-15",
},
)
The status flow is: DRAFT → OPEN (after the job in Step 7) → SENT → PAID. You can also cancel any non-finalized invoice via POST /api/v1/invoice/{id}/cancel.
Step 10: Export to DATEV (Optional)
For accounting handoff, you can export invoices in DATEV format. This involves a one-time configuration step followed by the actual export.
Configure DATEV (One-Time)
curl -X PUT https://docs101.com/api/v1/datev/config \
-H "X-API-Key: ak_live_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{
"consultant_number": 1234567,
"client_number": 12345,
"chart_of_accounts": "SKR03",
"fiscal_year_start_month": 1
}'
datev_config = {
"consultant_number": 1234567,
"client_number": 12345,
"chart_of_accounts": "SKR03",
"fiscal_year_start_month": 1,
}
requests.put(f"{BASE_URL}/datev/config", headers=HEADERS, json=datev_config)
Start the Export
curl -X POST https://docs101.com/api/v1/exports/job \
-H "X-API-Key: ak_live_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{
"task": "process_datev_export_job",
"start_date": "2026-01-01",
"end_date": "2026-03-31",
"include_master_data": true,
"include_documents": true,
"only_new": true
}'
export_data = {
"task": "process_datev_export_job",
"start_date": "2026-01-01",
"end_date": "2026-03-31",
"include_master_data": True,
"include_documents": True,
"only_new": True,
}
response = requests.post(f"{BASE_URL}/exports/job", headers=HEADERS, json=export_data)
export_job_id = response.json()["job_id"]
Poll the export job via GET /api/v1/exports/job/{job_id} until status: "finished". The finished response includes a result object with download_ids (an array of download IDs).
Download the Export
curl -H "X-API-Key: ak_live_YOUR_KEY_HERE" \
https://docs101.com/api/v1/downloads/{download_id}/download
# After polling confirms the job is finished:
result = export_status_response.json()["result"]
for download_id in result["download_ids"]:
response = requests.get(
f"{BASE_URL}/downloads/{download_id}/download", headers=HEADERS
)
download_url = response.json()["url"]
print(f"DATEV export: {download_url}")
The response contains a presigned S3 URL for the DATEV ZIP file.
Error Handling
| Status | Meaning | Typical Cause |
|---|---|---|
200 | Success | — |
201 | Resource created | Customer, invoice, position, or job created |
400 | Validation error | Missing required fields or invalid payload |
401 | Not authenticated | API key missing or invalid |
402 | Limit reached | Monthly invoice limit exceeded |
404 | Not found | Invalid resource ID |
422 | VAT override needed | Customer VAT ID invalid — resend with vat_override_confirmed: true |
For async jobs (PDF generation, DATEV export), implement exponential backoff when polling. Check the status field — if it is failed, inspect the error_message in the job response for details.
Complete Example
This script runs the entire workflow from Steps 2 through 9:
import requests
import time
API_KEY = "ak_live_YOUR_KEY_HERE"
BASE_URL = "https://docs101.com/api/v1"
HEADERS = {
"X-API-Key": API_KEY,
"Content-Type": "application/json",
}
# Step 2: Create a customer
customer = requests.post(
f"{BASE_URL}/customer/",
headers=HEADERS,
json={
"organization_name": "Acme Corp",
"email": "billing@acme.com",
"contact_type": "B2B",
"vat_id": "DE123456789",
"language": "English",
},
).json()
customer_id = customer["id"]
print(f"Customer created: {customer_id}")
# Step 3: Add a billing address
requests.post(
f"{BASE_URL}/customer/{customer_id}/addresses",
headers=HEADERS,
json={
"company": "Acme Corp",
"address_line_1": "Musterstraße 1",
"postal_code": "10115",
"city": "Berlin",
"country_code": "DE",
"is_default": True,
},
)
print("Address added")
# Step 4: Create an invoice
invoice = requests.post(
f"{BASE_URL}/invoice/",
headers=HEADERS,
json={
"customer_id": customer_id,
"benefit_period_start": "2026-04-01",
"benefit_period_end": "2026-04-30",
"invoice_format": "ZUGFERD",
},
).json()
invoice_id = invoice["invoice_id"]
print(f"Invoice created: {invoice_id}")
# Step 5: Add a line item
requests.post(
f"{BASE_URL}/invoice/{invoice_id}/positions",
headers=HEADERS,
json={
"title": "Pro Plan — Monthly Subscription",
"description": "April 2026",
"quantity": 1.0,
"unit_id": "HUR",
"unit_net_amount": 25.00,
"single_net_amount": 25.00,
"tax_rate": 0.19,
"tax_treatment_id": "standard",
},
)
print("Position added")
# Step 6: Validate
validation = requests.get(
f"{BASE_URL}/invoice/{invoice_id}/validate", headers=HEADERS
).json()
if not validation["valid"]:
print(f"Validation failed: {validation['errors']}")
exit(1)
print("Invoice validated")
# Step 7: Finalize (generate PDF)
job = requests.post(
f"{BASE_URL}/invoice/{invoice_id}/job",
headers=HEADERS,
json={"task": "process_invoice_flag_invoice_send_job"},
).json()
job_id = job["job_id"]
print(f"PDF generation started: {job_id}")
for _ in range(30):
time.sleep(2)
status = requests.get(
f"{BASE_URL}/invoice/{invoice_id}/job/{job_id}", headers=HEADERS
).json()["status"]
if status == "finished":
print("PDF generated")
break
elif status == "failed":
print("PDF generation failed")
exit(1)
else:
print("Timeout waiting for PDF generation")
exit(1)
# Step 8: Download the PDF
pdf = requests.get(
f"{BASE_URL}/invoice/{invoice_id}/pdf-link", headers=HEADERS
).json()
print(f"PDF ready: {pdf['filename']} — {pdf['url']}")
# Step 9: Mark as sent
requests.put(
f"{BASE_URL}/invoice/{invoice_id}/status",
headers=HEADERS,
json={"status": "SENT"},
)
print("Invoice marked as sent")
Reference Links
- API Quickstart — Generate your API key
- Authentication — API key usage with curl, Python, JavaScript, PHP
- API Versioning — Non-breaking vs. breaking changes
- Swagger UI — Full interactive API reference
- VAT Category Codes — Tax classification codes
- Unit Codes — Units of measurement (HUR, C62, MON, etc.)
- ZUGFeRD Explained — Background on the ZUGFeRD e-invoice format
- Reverse Charge — When and how reverse charge applies