Die vollständige API-Referenz, direkt aus der OpenAPI-Spezifikation generiert. Alle Endpunkte, Request-/Response-Schemata und Felder. Read-only — für den interaktiven Zugang mit eigenem Test-Key siehe die Entwickler-Plattform.
OpenAPI-Spezifikation herunterladen (YAML) — für die Generierung eigener Clients & SDKs (OpenAPI 3.0)
Invoicing API (5.19.0)
Download OpenAPI specification:
Backend contract for the invoicing application. This service owns business logic, validation, totals/VAT calculation, persistence, and schema. Money and quantity values are transmitted as DECIMAL STRINGS (e.g. "9542.50", "86.75") to preserve exact precision. Totals are ALWAYS computed server-side; any totals supplied in a request body are ignored. The model is aligned with EN 16931 / ZUGFeRD concepts so a compliant export can be added later. v1 exposes only a subset.
The published base URL authenticates programmatic API keys (Authorization: Bearer sk_live_… / sk_test_…). Present a key on every request; an access token issued to a signed-in person is not accepted here and is refused before the request reaches the application.
That is why a large part of this reference is marked as requiring a signed-in session rather than a key: account and administration work — team and membership changes, company master data, key and webhook management, billing and the archive — is performed in the web dashboard, and those operations are documented here so the behaviour they implement is inspectable, not because they are callable from this base URL. If an operation is documented as requiring a signed-in session, reach for the dashboard rather than a key.
Issuing, sending and retrieving documents — the surface an integration actually automates — is fully available with a key.
A refused request returns 429 with the rate_limited error code, a Retry-After header and the X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset trio. Honour Retry-After, and treat the headers as the authoritative budget for the current window.
Any request budget we state is a FLOOR — the number we guarantee to accept — and never a ceiling. Observing more being accepted than the stated figure is not a promise that it will continue, so do not calibrate a client against what it happens to get away with. No figure is published yet; it will be stated only once it has been measured against real traffic.
Create, issue, and manage invoices, credit notes, and their PDF/XML artifacts and validation.
List invoice summaries (newest first, paginated)
Authorizations:
query Parameters
| page | integer >= 1 Default: 1 Page number (1-indexed). |
| pageSize | integer [ 1 .. 100 ] Default: 25 Number of items per page. |
| status | string Enum: "draft" "scheduled" "issued" "paid" "void" Filter by invoice status. Omit to return all statuses. |
| correctionState | string Enum: "cancelled" "corrected" "none" Filter by the DERIVED correction state — computed from the document chain (the preceding-invoice link), never stored. |
| validationState | string Enum: "failed" "passed" "legacy" Compliance-gate filter. |
| customerId | string <uuid> Return only documents linked to this customer (exact match on the customer link). Documents created for an ad-hoc buyer carry no customer link and are therefore never returned when this filter is set. |
| q | string <= 100 characters Free-text filter, case-insensitive substring match over a CLOSED per-resource field set (invoices: invoice number + buyer name; offers: offer number + buyer name; customers: name + email). Trimmed; an empty/whitespace-only value is treated as absent, not as an error. Results are bounded by the pagination limit and by your own entity's scope. Deliberately NOT a fuzzy/ranked search — it is a plain substring match, and it does not search line items, notes or amounts. The term is never written to logs, audit records or webhook payloads. |
| transmissionProblem | string Value: "true" Return only documents whose latest system-performed dispatch is in a PROBLEM state — |
Responses
Production
Response samples
- 200
- 400
{- "data": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24",
- "status": "draft",
- "currency": "string",
- "buyerName": "string",
- "totalGross": "9542.50",
- "validationState": "failed",
- "correctionState": "cancelled",
- "transmissionState": "not_sent",
- "transmissionProblemReason": "hard_bounce",
- "transmissionLastEventAt": "2019-08-24T14:15:22Z",
- "mode": "live"
}
], - "pagination": {
- "total": 847,
- "page": 3,
- "pageSize": 25,
- "totalPages": 34
}
}Create an invoice (server computes all totals)
Honors the optional Idempotency-Key header so a retried DRAFT create is a replay, not a duplicate. NB: issuance (PATCH /invoices/{id}/finalize) is deliberately NOT idempotency-wrapped — it is idempotent by domain construction (numbering sticky-lock + one-per-finalize metering).
Authorizations:
header Parameters
| Idempotency-Key | string <= 255 characters Optional client-supplied key that makes this mutating request safe to retry. The FIRST request runs and its outcome is recorded keyed by |
Request Body schema: application/jsonrequired
| currency required | string ISO 4217 (3 letters) |
| invoiceTypeCode | string UNTDID 1001 (BT-3). Defaults to "380" if omitted. Allowlist- checked against |
object EN 16931 BG-3 reference. REQUIRED when invoiceTypeCode="384" (correction); a missing, dangling, draft/scheduled, or otherwise unresolvable reference is a 422 | |
| issueDate | string <date> On a create, this defaults to today. On the replace of a draft, omitting it KEEPS the date already stored — it is one of three properties on this body that are kept rather than cleared, which is why each of them says so here. |
| dueDate | string or null <date> EN 16931 BT-9 payment due date. Optional; when omitted the render clients fall back to issueDate + 30 days. Frozen at issue by the 0058/0059 immutability trigger. |
| paymentTerms | string or null EN 16931 BT-20 free-text payment terms / Zahlungsziel. Optional; frozen at issue. |
| customerId | string <uuid> Optional link to a saved customer; buyer is still snapshotted |
| documentTarget | string Enum: "b2b" "b2g" Optional per-invoice override of whether the recipient is a public authority. Omitting it does NOT clear it: the server resolves the linked customer's setting, then this draft's own stored value, so a replace that does not mention it keeps what the document already says. Snapshotted onto the invoice and frozen at issue. Note that the same property behaves differently on the customer replace, where omitting it sets 'b2b'. |
| status | string Enum: "draft" "scheduled" Desired initial status; defaults to "draft" when omitted. "issued" is NOT accepted — issued documents are reachable only via the finalize / internal-issue endpoints, which assign the invoice number and generate the artifacts. |
required | object (Party) Seller or buyer (EN 16931 BG-4 / BG-7, simplified). |
object Optional; defaults to the configured seller | |
| buyerReference | string |
| buyerElectronicAddress | string <= 200 characters EN 16931 BT-49 — the buyer's ELECTRONIC ROUTING address (a Leitweg-ID for B2G, or an email), used when generating XRechnung. Falls back to |
| title | string or null <= 500 characters Document title / Betreff, rendered above the line items. On a create, OMITTING the field applies the company-wide default title; an explicit |
| text | string or null <= 5000 characters Free introductory body text rendered above the line items. Distinct from |
| buyerEmail | string <email> Recipient email (prefilled from the customer) |
| notes | string or null <= 2000 characters Note / footer text rendered below the line items. On a create, OMITTING the field applies the company-wide default note; an explicit |
| invoiceNo | string Optional custom invoice number, used verbatim (uniqueness checked, counter NOT incremented). For a Dauerrechnung this is where the static, contract-derived Objektnummer goes (UStAE 14.5 Abs. 12), which satisfies §14 Abs. 4 Nr. 4. On a replace this property distinguishes the two cases explicitly: omitting it keeps the stored number, sending an empty string clears it. |
| billingPeriodStart | string <date> EN 16931 BT-73 — service/billing period start. For a Dauerrechnung this is the Leistungszeitraum "von" satisfying §14 Abs. 4 Nr. 6. |
| billingPeriodEnd | string <date> EN 16931 BT-74 — service/billing period end (Leistungszeitraum "bis"; leave unset for an open-ended continuous supply). |
| deliveryDate | string or null <date> EN 16931 BT-72 — actual delivery date (single-point Leistungsdatum) satisfying §14 Abs. 4 Nr. 6 for a one-off invoice whose service date differs from the issue date. Mutually exclusive with |
| isDauerrechnung | boolean Mark this invoice as a Dauerrechnung (continuous-supply invoice, UStAE 14.5 Abs. 17 self-identifying branch). Orthogonal to |
required | Array of objects (CreateInvoiceLineItem) non-empty |
Responses
Production
Request samples
- Payload
{- "currency": "EUR",
- "invoiceTypeCode": "380",
- "buyer": {
- "name": "Achsfeld Transporte GmbH",
- "addressLine1": "Gewerbestraße 8",
- "postalCode": "59192",
- "city": "Bergkamen",
- "countryCode": "DE",
- "vatId": "DE245817096"
}, - "buyerReference": "FP-2026-0219",
- "deliveryDate": "2026-07-31",
- "lineItems": [
- {
- "description": "Ladevorgänge Januar",
- "quantity": "1",
- "unitCode": "C62",
- "unitPriceNet": "420.00",
- "taxRatePercent": "19"
}
]
}Response samples
- 201
- 400
- 409
- 422
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24",
- "status": "draft",
- "currency": "string",
- "buyerName": "string",
- "totalGross": "9542.50",
- "validationState": "failed",
- "correctionState": "cancelled",
- "transmissionState": "not_sent",
- "transmissionProblemReason": "hard_bounce",
- "transmissionLastEventAt": "2019-08-24T14:15:22Z",
- "mode": "live",
- "sendState": {
- "class": "none",
- "recordedAt": "2019-08-24T14:15:22Z",
- "assertedOn": "2019-08-24"
}, - "transmission": {
- "state": "not_sent",
- "heldReason": "tenant_paused",
- "problemReason": "hard_bounce",
- "lastEventAt": "2019-08-24T14:15:22Z",
- "sender": {
- "kind": "tenant_smtp",
- "fromAddress": "string",
- "replyTo": "string"
}, - "recipientSuppressed": true,
- "actions": [
- "correct_address"
]
}, - "capabilities": {
- "externalSendRecord": true,
- "adHocRecipient": true
}, - "dueDate": "2019-08-24",
- "paymentTerms": "string",
- "invoiceTypeCode": "380",
- "reversalScope": "full",
- "precedingInvoice": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24"
}, - "correctedBy": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "sourceOfferId": "27f452e8-4c83-478a-af96-74a338a701ab",
- "sourceOffer": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "offerNumber": "string"
}, - "correctionDocuments": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "invoiceTypeCode": "381",
- "reversalScope": "full",
- "issueDate": "2019-08-24"
}
], - "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "documentTarget": "b2b",
- "seller": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "sellerIsKleinunternehmer": true,
- "isDauerrechnung": true,
- "dauerrechnungState": "active",
- "buyer": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyerReference": "string",
- "buyerEmail": "user@example.com",
- "notes": "string",
- "lineItems": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "description": "string",
- "quantity": "9542.50",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "lineNetAmount": "9542.50",
- "taxRatePercent": "9542.50",
- "itemDescription": "string",
- "vatCategory": "S",
- "vatExemptionReasonCode": "string",
- "vatExemptionReasonText": "string"
}
], - "totals": {
- "totalNet": "9542.50",
- "totalTax": "9542.50",
- "totalGross": "9542.50"
}, - "taxBreakdown": [
- {
- "taxRatePercent": "9542.50",
- "taxableAmount": "9542.50",
- "taxAmount": "9542.50"
}
], - "issuedAt": "2019-08-24T14:15:22Z",
- "processingRestricted": true,
- "processingRestrictionReason": "article_18_gdpr_request",
- "restrictedAt": "2019-08-24T14:15:22Z",
- "restrictedBy": "9f682db5-e5ef-4021-aef3-5e4a0ea64036",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Preview the totals for a set of line items (nothing is created)
Returns the document totals, VAT breakdown and per-line net amounts that an invoice with these line items would get — computed by exactly the same server-side engine POST /invoices uses, so the previewed totals are byte-identical to the ones issuance persists for the same line items.
Nothing is created or consumed. No invoice, no invoice number, no numbering event, no metering record and no outbox entry — so no Idempotency-Key is accepted or needed, and the endpoint is safe to call as often, as concurrently and as out-of-order as a form's keystrokes demand. It is a read of would-be values, hence documents:read rather than documents:write, and it needs no persisted draft — which is the whole point: it previews an unsaved form.
Tax treatment is resolved on the server. Kleinunternehmer status (§ 19 UStG) is read from your entity's company profile; there is no field to send, and a client may not decide it. A Kleinunternehmer entity's preview therefore already shows the exempt (E) treatment its issued invoice will carry.
What this does NOT do: it is not a validation verdict (use POST /invoices/{id}/validate or POST /validate), and it cannot detect a mistyped amount — 1250 is a valid decimal and the server has no way to know 12.50 was meant. It shows the consequence of the numbers actually submitted, so a human can catch one that looks wrong.
Authorizations:
Request Body schema: application/jsonrequired
required | Array of objects (CreateInvoiceLineItem) non-empty |
Responses
Production
Request samples
- Payload
{- "lineItems": [
- {
- "description": "string",
- "quantity": "0.25",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "taxRatePercent": "19",
- "itemDescription": "string",
- "vatCategory": "S"
}
]
}Response samples
- 200
- 400
- 422
{- "totals": {
- "totalNet": "9542.50",
- "totalTax": "9542.50",
- "totalGross": "9542.50"
}, - "taxBreakdown": [
- {
- "taxRatePercent": "9542.50",
- "taxableAmount": "9542.50",
- "taxAmount": "9542.50"
}
], - "lines": [
- {
- "lineNetAmount": "9542.50"
}
]
}Preview the next auto-generated invoice number (no side effects)
Read-only preview — does NOT consume the counter. The number actually assigned at issue time (PATCH /invoices/{id}/finalize) can differ if another invoice is issued in between (the counter is only reserved atomically at issue time, not here).
Authorizations:
query Parameters
| issueDate | string <date> ISO date (YYYY-MM-DD) the number's date segment would use. Defaults to today. |
Responses
Production
Response samples
- 200
{- "nextNumber": "string"
}Get a full invoice by id
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24",
- "status": "draft",
- "currency": "string",
- "buyerName": "string",
- "totalGross": "9542.50",
- "validationState": "failed",
- "correctionState": "cancelled",
- "transmissionState": "not_sent",
- "transmissionProblemReason": "hard_bounce",
- "transmissionLastEventAt": "2019-08-24T14:15:22Z",
- "mode": "live",
- "sendState": {
- "class": "none",
- "recordedAt": "2019-08-24T14:15:22Z",
- "assertedOn": "2019-08-24"
}, - "transmission": {
- "state": "not_sent",
- "heldReason": "tenant_paused",
- "problemReason": "hard_bounce",
- "lastEventAt": "2019-08-24T14:15:22Z",
- "sender": {
- "kind": "tenant_smtp",
- "fromAddress": "string",
- "replyTo": "string"
}, - "recipientSuppressed": true,
- "actions": [
- "correct_address"
]
}, - "capabilities": {
- "externalSendRecord": true,
- "adHocRecipient": true
}, - "dueDate": "2019-08-24",
- "paymentTerms": "string",
- "invoiceTypeCode": "380",
- "reversalScope": "full",
- "precedingInvoice": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24"
}, - "correctedBy": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "sourceOfferId": "27f452e8-4c83-478a-af96-74a338a701ab",
- "sourceOffer": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "offerNumber": "string"
}, - "correctionDocuments": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "invoiceTypeCode": "381",
- "reversalScope": "full",
- "issueDate": "2019-08-24"
}
], - "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "documentTarget": "b2b",
- "seller": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "sellerIsKleinunternehmer": true,
- "isDauerrechnung": true,
- "dauerrechnungState": "active",
- "buyer": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyerReference": "string",
- "buyerEmail": "user@example.com",
- "notes": "string",
- "lineItems": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "description": "string",
- "quantity": "9542.50",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "lineNetAmount": "9542.50",
- "taxRatePercent": "9542.50",
- "itemDescription": "string",
- "vatCategory": "S",
- "vatExemptionReasonCode": "string",
- "vatExemptionReasonText": "string"
}
], - "totals": {
- "totalNet": "9542.50",
- "totalTax": "9542.50",
- "totalGross": "9542.50"
}, - "taxBreakdown": [
- {
- "taxRatePercent": "9542.50",
- "taxableAmount": "9542.50",
- "taxAmount": "9542.50"
}
], - "issuedAt": "2019-08-24T14:15:22Z",
- "processingRestricted": true,
- "processingRestrictionReason": "article_18_gdpr_request",
- "restrictedAt": "2019-08-24T14:15:22Z",
- "restrictedBy": "9f682db5-e5ef-4021-aef3-5e4a0ea64036",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Replace a draft invoice (full replace of all mutable fields; server recomputes totals)
Draft only — always 409 on a non-draft invoice, regardless of which fields the request tries to change (numbers, artifacts, and totals of an issued document are immutable). Same request/validation shape as POST /invoices (updateInvoiceSchema is createInvoiceSchema), including the invoiceTypeCode allowlist check (400) and the precedingInvoice/negative-amount checks (422, same as create).
Authorizations:
path Parameters
| id required | string <uuid> |
Request Body schema: application/jsonrequired
| currency required | string ISO 4217 (3 letters) |
| invoiceTypeCode | string UNTDID 1001 (BT-3). Defaults to "380" if omitted. Allowlist- checked against |
object EN 16931 BG-3 reference. REQUIRED when invoiceTypeCode="384" (correction); a missing, dangling, draft/scheduled, or otherwise unresolvable reference is a 422 | |
| issueDate | string <date> On a create, this defaults to today. On the replace of a draft, omitting it KEEPS the date already stored — it is one of three properties on this body that are kept rather than cleared, which is why each of them says so here. |
| dueDate | string or null <date> EN 16931 BT-9 payment due date. Optional; when omitted the render clients fall back to issueDate + 30 days. Frozen at issue by the 0058/0059 immutability trigger. |
| paymentTerms | string or null EN 16931 BT-20 free-text payment terms / Zahlungsziel. Optional; frozen at issue. |
| customerId | string <uuid> Optional link to a saved customer; buyer is still snapshotted |
| documentTarget | string Enum: "b2b" "b2g" Optional per-invoice override of whether the recipient is a public authority. Omitting it does NOT clear it: the server resolves the linked customer's setting, then this draft's own stored value, so a replace that does not mention it keeps what the document already says. Snapshotted onto the invoice and frozen at issue. Note that the same property behaves differently on the customer replace, where omitting it sets 'b2b'. |
| status | string Enum: "draft" "scheduled" Desired initial status; defaults to "draft" when omitted. "issued" is NOT accepted — issued documents are reachable only via the finalize / internal-issue endpoints, which assign the invoice number and generate the artifacts. |
required | object (Party) Seller or buyer (EN 16931 BG-4 / BG-7, simplified). |
object Optional; defaults to the configured seller | |
| buyerReference | string |
| buyerElectronicAddress | string <= 200 characters EN 16931 BT-49 — the buyer's ELECTRONIC ROUTING address (a Leitweg-ID for B2G, or an email), used when generating XRechnung. Falls back to |
| title | string or null <= 500 characters Document title / Betreff, rendered above the line items. On a create, OMITTING the field applies the company-wide default title; an explicit |
| text | string or null <= 5000 characters Free introductory body text rendered above the line items. Distinct from |
| buyerEmail | string <email> Recipient email (prefilled from the customer) |
| notes | string or null <= 2000 characters Note / footer text rendered below the line items. On a create, OMITTING the field applies the company-wide default note; an explicit |
| invoiceNo | string Optional custom invoice number, used verbatim (uniqueness checked, counter NOT incremented). For a Dauerrechnung this is where the static, contract-derived Objektnummer goes (UStAE 14.5 Abs. 12), which satisfies §14 Abs. 4 Nr. 4. On a replace this property distinguishes the two cases explicitly: omitting it keeps the stored number, sending an empty string clears it. |
| billingPeriodStart | string <date> EN 16931 BT-73 — service/billing period start. For a Dauerrechnung this is the Leistungszeitraum "von" satisfying §14 Abs. 4 Nr. 6. |
| billingPeriodEnd | string <date> EN 16931 BT-74 — service/billing period end (Leistungszeitraum "bis"; leave unset for an open-ended continuous supply). |
| deliveryDate | string or null <date> EN 16931 BT-72 — actual delivery date (single-point Leistungsdatum) satisfying §14 Abs. 4 Nr. 6 for a one-off invoice whose service date differs from the issue date. Mutually exclusive with |
| isDauerrechnung | boolean Mark this invoice as a Dauerrechnung (continuous-supply invoice, UStAE 14.5 Abs. 17 self-identifying branch). Orthogonal to |
required | Array of objects (CreateInvoiceLineItem) non-empty |
Responses
Production
Request samples
- Payload
{- "currency": "string",
- "invoiceTypeCode": "380",
- "precedingInvoice": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string"
}, - "issueDate": "2019-08-24",
- "dueDate": "2019-08-24",
- "paymentTerms": "string",
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "documentTarget": "b2b",
- "status": "draft",
- "buyer": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "seller": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyerReference": "string",
- "buyerElectronicAddress": "string",
- "title": "string",
- "text": "string",
- "buyerEmail": "user@example.com",
- "notes": "string",
- "invoiceNo": "string",
- "billingPeriodStart": "2019-08-24",
- "billingPeriodEnd": "2019-08-24",
- "deliveryDate": "2019-08-24",
- "isDauerrechnung": true,
- "lineItems": [
- {
- "description": "string",
- "quantity": "0.25",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "taxRatePercent": "19",
- "itemDescription": "string",
- "vatCategory": "S"
}
]
}Response samples
- 200
- 400
- 409
- 422
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24",
- "status": "draft",
- "currency": "string",
- "buyerName": "string",
- "totalGross": "9542.50",
- "validationState": "failed",
- "correctionState": "cancelled",
- "transmissionState": "not_sent",
- "transmissionProblemReason": "hard_bounce",
- "transmissionLastEventAt": "2019-08-24T14:15:22Z",
- "mode": "live",
- "sendState": {
- "class": "none",
- "recordedAt": "2019-08-24T14:15:22Z",
- "assertedOn": "2019-08-24"
}, - "transmission": {
- "state": "not_sent",
- "heldReason": "tenant_paused",
- "problemReason": "hard_bounce",
- "lastEventAt": "2019-08-24T14:15:22Z",
- "sender": {
- "kind": "tenant_smtp",
- "fromAddress": "string",
- "replyTo": "string"
}, - "recipientSuppressed": true,
- "actions": [
- "correct_address"
]
}, - "capabilities": {
- "externalSendRecord": true,
- "adHocRecipient": true
}, - "dueDate": "2019-08-24",
- "paymentTerms": "string",
- "invoiceTypeCode": "380",
- "reversalScope": "full",
- "precedingInvoice": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24"
}, - "correctedBy": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "sourceOfferId": "27f452e8-4c83-478a-af96-74a338a701ab",
- "sourceOffer": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "offerNumber": "string"
}, - "correctionDocuments": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "invoiceTypeCode": "381",
- "reversalScope": "full",
- "issueDate": "2019-08-24"
}
], - "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "documentTarget": "b2b",
- "seller": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "sellerIsKleinunternehmer": true,
- "isDauerrechnung": true,
- "dauerrechnungState": "active",
- "buyer": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyerReference": "string",
- "buyerEmail": "user@example.com",
- "notes": "string",
- "lineItems": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "description": "string",
- "quantity": "9542.50",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "lineNetAmount": "9542.50",
- "taxRatePercent": "9542.50",
- "itemDescription": "string",
- "vatCategory": "S",
- "vatExemptionReasonCode": "string",
- "vatExemptionReasonText": "string"
}
], - "totals": {
- "totalNet": "9542.50",
- "totalTax": "9542.50",
- "totalGross": "9542.50"
}, - "taxBreakdown": [
- {
- "taxRatePercent": "9542.50",
- "taxableAmount": "9542.50",
- "taxAmount": "9542.50"
}
], - "issuedAt": "2019-08-24T14:15:22Z",
- "processingRestricted": true,
- "processingRestrictionReason": "article_18_gdpr_request",
- "restrictedAt": "2019-08-24T14:15:22Z",
- "restrictedBy": "9f682db5-e5ef-4021-aef3-5e4a0ea64036",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Delete a draft invoice
Draft only. An issued/paid/void invoice is immutable (GoBD), so a delete on a non-draft is refused with 409 not_draft — the correction path for an issued document is a credit note (POST /invoices/{id}/credit-note), never a delete. Idempotent from the caller's view: a missing invoice is a plain 404.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 404
- 409
{- "error": "validation_error",
- "issues": [
- {
- "path": "string",
- "message": "string"
}
]
}Download the stored ZUGFeRD PDF for an issued invoice
Streams the persisted artifact (invoices.pdf_key, written at issue time by the compliance-gate orchestrator). 404 whenever it is not ready to serve — draft, pdf_status not 'ready', or the object is unexpectedly missing from storage — the response body does not distinguish which. OIDC-only — an API key cannot read this PDF.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 404
{- "error": "validation_error",
- "issues": [
- {
- "path": "string",
- "message": "string"
}
]
}Download the stored XRechnung 3.0 CII XML for an issued invoice
Streams the persisted artifact (invoices.xml_key, written at issue time). 404 whenever it is not ready to serve — draft, xml_status not 'ready', or the object is unexpectedly missing from storage.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 404
{- "error": "validation_error",
- "issues": [
- {
- "path": "string",
- "message": "string"
}
]
}Render a non-persisted, watermarked WYSIWYG preview PDF for a draft
Draft only (409 otherwise). Rendered on demand by the document renderer — the same template and renderer as the real PDF, so this is byte-faithful to what PATCH /invoices/{id}/finalize will produce. Nothing is stored; a fresh render happens on every call.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 404
- 409
- 422
{- "error": "validation_error",
- "issues": [
- {
- "path": "string",
- "message": "string"
}
]
}Reset a failed PDF render to pending and retry generation
Requires an issued invoice whose pdf_status is 'failed'.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 200
- 409
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24",
- "status": "draft",
- "currency": "string",
- "buyerName": "string",
- "totalGross": "9542.50",
- "validationState": "failed",
- "correctionState": "cancelled",
- "transmissionState": "not_sent",
- "transmissionProblemReason": "hard_bounce",
- "transmissionLastEventAt": "2019-08-24T14:15:22Z",
- "mode": "live",
- "sendState": {
- "class": "none",
- "recordedAt": "2019-08-24T14:15:22Z",
- "assertedOn": "2019-08-24"
}, - "transmission": {
- "state": "not_sent",
- "heldReason": "tenant_paused",
- "problemReason": "hard_bounce",
- "lastEventAt": "2019-08-24T14:15:22Z",
- "sender": {
- "kind": "tenant_smtp",
- "fromAddress": "string",
- "replyTo": "string"
}, - "recipientSuppressed": true,
- "actions": [
- "correct_address"
]
}, - "capabilities": {
- "externalSendRecord": true,
- "adHocRecipient": true
}, - "dueDate": "2019-08-24",
- "paymentTerms": "string",
- "invoiceTypeCode": "380",
- "reversalScope": "full",
- "precedingInvoice": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24"
}, - "correctedBy": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "sourceOfferId": "27f452e8-4c83-478a-af96-74a338a701ab",
- "sourceOffer": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "offerNumber": "string"
}, - "correctionDocuments": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "invoiceTypeCode": "381",
- "reversalScope": "full",
- "issueDate": "2019-08-24"
}
], - "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "documentTarget": "b2b",
- "seller": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "sellerIsKleinunternehmer": true,
- "isDauerrechnung": true,
- "dauerrechnungState": "active",
- "buyer": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyerReference": "string",
- "buyerEmail": "user@example.com",
- "notes": "string",
- "lineItems": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "description": "string",
- "quantity": "9542.50",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "lineNetAmount": "9542.50",
- "taxRatePercent": "9542.50",
- "itemDescription": "string",
- "vatCategory": "S",
- "vatExemptionReasonCode": "string",
- "vatExemptionReasonText": "string"
}
], - "totals": {
- "totalNet": "9542.50",
- "totalTax": "9542.50",
- "totalGross": "9542.50"
}, - "taxBreakdown": [
- {
- "taxRatePercent": "9542.50",
- "taxableAmount": "9542.50",
- "taxAmount": "9542.50"
}
], - "issuedAt": "2019-08-24T14:15:22Z",
- "processingRestricted": true,
- "processingRestrictionReason": "article_18_gdpr_request",
- "restrictedAt": "2019-08-24T14:15:22Z",
- "restrictedBy": "9f682db5-e5ef-4021-aef3-5e4a0ea64036",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Reset a failed XML render to pending and retry generation
Requires an issued invoice whose xml_status is 'failed'.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 200
- 409
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24",
- "status": "draft",
- "currency": "string",
- "buyerName": "string",
- "totalGross": "9542.50",
- "validationState": "failed",
- "correctionState": "cancelled",
- "transmissionState": "not_sent",
- "transmissionProblemReason": "hard_bounce",
- "transmissionLastEventAt": "2019-08-24T14:15:22Z",
- "mode": "live",
- "sendState": {
- "class": "none",
- "recordedAt": "2019-08-24T14:15:22Z",
- "assertedOn": "2019-08-24"
}, - "transmission": {
- "state": "not_sent",
- "heldReason": "tenant_paused",
- "problemReason": "hard_bounce",
- "lastEventAt": "2019-08-24T14:15:22Z",
- "sender": {
- "kind": "tenant_smtp",
- "fromAddress": "string",
- "replyTo": "string"
}, - "recipientSuppressed": true,
- "actions": [
- "correct_address"
]
}, - "capabilities": {
- "externalSendRecord": true,
- "adHocRecipient": true
}, - "dueDate": "2019-08-24",
- "paymentTerms": "string",
- "invoiceTypeCode": "380",
- "reversalScope": "full",
- "precedingInvoice": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24"
}, - "correctedBy": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "sourceOfferId": "27f452e8-4c83-478a-af96-74a338a701ab",
- "sourceOffer": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "offerNumber": "string"
}, - "correctionDocuments": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "invoiceTypeCode": "381",
- "reversalScope": "full",
- "issueDate": "2019-08-24"
}
], - "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "documentTarget": "b2b",
- "seller": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "sellerIsKleinunternehmer": true,
- "isDauerrechnung": true,
- "dauerrechnungState": "active",
- "buyer": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyerReference": "string",
- "buyerEmail": "user@example.com",
- "notes": "string",
- "lineItems": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "description": "string",
- "quantity": "9542.50",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "lineNetAmount": "9542.50",
- "taxRatePercent": "9542.50",
- "itemDescription": "string",
- "vatCategory": "S",
- "vatExemptionReasonCode": "string",
- "vatExemptionReasonText": "string"
}
], - "totals": {
- "totalNet": "9542.50",
- "totalTax": "9542.50",
- "totalGross": "9542.50"
}, - "taxBreakdown": [
- {
- "taxRatePercent": "9542.50",
- "taxableAmount": "9542.50",
- "taxAmount": "9542.50"
}
], - "issuedAt": "2019-08-24T14:15:22Z",
- "processingRestricted": true,
- "processingRestrictionReason": "article_18_gdpr_request",
- "restrictedAt": "2019-08-24T14:15:22Z",
- "restrictedBy": "9f682db5-e5ef-4021-aef3-5e4a0ea64036",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Set or clear the DSGVO Art. 18 processing restriction
Document-level DSGVO Art. 18 processing restriction. Setting restricted: true requires a reason; it stamps restrictedAt/ restrictedBy and writes an audit-log entry documenting the conflict. Clearing (restricted: false) resets the reason/actor/timestamp. A restriction is metadata ABOUT the document — it never alters the issued content, and a restricted document still fully archives, exports, and appears in DATEV output (restriction limits processing, not existence). Role-gated owner/admin.
Authorizations:
path Parameters
| id required | string <uuid> |
Request Body schema: application/jsonrequired
| restricted required | boolean true = set the restriction, false = clear it. |
| reason | string Enum: "article_18_gdpr_request" "legal_dispute" "disputed_accuracy" Required when |
Responses
Production
Request samples
- Payload
{- "restricted": true,
- "reason": "article_18_gdpr_request"
}Response samples
- 200
- 400
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24",
- "status": "draft",
- "currency": "string",
- "buyerName": "string",
- "totalGross": "9542.50",
- "validationState": "failed",
- "correctionState": "cancelled",
- "transmissionState": "not_sent",
- "transmissionProblemReason": "hard_bounce",
- "transmissionLastEventAt": "2019-08-24T14:15:22Z",
- "mode": "live",
- "sendState": {
- "class": "none",
- "recordedAt": "2019-08-24T14:15:22Z",
- "assertedOn": "2019-08-24"
}, - "transmission": {
- "state": "not_sent",
- "heldReason": "tenant_paused",
- "problemReason": "hard_bounce",
- "lastEventAt": "2019-08-24T14:15:22Z",
- "sender": {
- "kind": "tenant_smtp",
- "fromAddress": "string",
- "replyTo": "string"
}, - "recipientSuppressed": true,
- "actions": [
- "correct_address"
]
}, - "capabilities": {
- "externalSendRecord": true,
- "adHocRecipient": true
}, - "dueDate": "2019-08-24",
- "paymentTerms": "string",
- "invoiceTypeCode": "380",
- "reversalScope": "full",
- "precedingInvoice": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24"
}, - "correctedBy": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "sourceOfferId": "27f452e8-4c83-478a-af96-74a338a701ab",
- "sourceOffer": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "offerNumber": "string"
}, - "correctionDocuments": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "invoiceTypeCode": "381",
- "reversalScope": "full",
- "issueDate": "2019-08-24"
}
], - "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "documentTarget": "b2b",
- "seller": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "sellerIsKleinunternehmer": true,
- "isDauerrechnung": true,
- "dauerrechnungState": "active",
- "buyer": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyerReference": "string",
- "buyerEmail": "user@example.com",
- "notes": "string",
- "lineItems": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "description": "string",
- "quantity": "9542.50",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "lineNetAmount": "9542.50",
- "taxRatePercent": "9542.50",
- "itemDescription": "string",
- "vatCategory": "S",
- "vatExemptionReasonCode": "string",
- "vatExemptionReasonText": "string"
}
], - "totals": {
- "totalNet": "9542.50",
- "totalTax": "9542.50",
- "totalGross": "9542.50"
}, - "taxBreakdown": [
- {
- "taxRatePercent": "9542.50",
- "taxableAmount": "9542.50",
- "taxAmount": "9542.50"
}
], - "issuedAt": "2019-08-24T14:15:22Z",
- "processingRestricted": true,
- "processingRestrictionReason": "article_18_gdpr_request",
- "restrictedAt": "2019-08-24T14:15:22Z",
- "restrictedBy": "9f682db5-e5ef-4021-aef3-5e4a0ea64036",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Transition a draft to 'scheduled' with an auto-issuance date
A scheduling worker (a separate process) polls for invoices whose scheduledIssuanceDate has arrived and issues them automatically. No number is assigned here — numbering only happens at issue time.
Authorizations:
path Parameters
| id required | string <uuid> |
Request Body schema: application/jsonrequired
| scheduledIssuanceDate required | string <date> |
Responses
Production
Request samples
- Payload
{- "scheduledIssuanceDate": "2019-08-24"
}Response samples
- 200
- 400
- 409
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24",
- "status": "draft",
- "currency": "string",
- "buyerName": "string",
- "totalGross": "9542.50",
- "validationState": "failed",
- "correctionState": "cancelled",
- "transmissionState": "not_sent",
- "transmissionProblemReason": "hard_bounce",
- "transmissionLastEventAt": "2019-08-24T14:15:22Z",
- "mode": "live",
- "sendState": {
- "class": "none",
- "recordedAt": "2019-08-24T14:15:22Z",
- "assertedOn": "2019-08-24"
}, - "transmission": {
- "state": "not_sent",
- "heldReason": "tenant_paused",
- "problemReason": "hard_bounce",
- "lastEventAt": "2019-08-24T14:15:22Z",
- "sender": {
- "kind": "tenant_smtp",
- "fromAddress": "string",
- "replyTo": "string"
}, - "recipientSuppressed": true,
- "actions": [
- "correct_address"
]
}, - "capabilities": {
- "externalSendRecord": true,
- "adHocRecipient": true
}, - "dueDate": "2019-08-24",
- "paymentTerms": "string",
- "invoiceTypeCode": "380",
- "reversalScope": "full",
- "precedingInvoice": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24"
}, - "correctedBy": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "sourceOfferId": "27f452e8-4c83-478a-af96-74a338a701ab",
- "sourceOffer": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "offerNumber": "string"
}, - "correctionDocuments": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "invoiceTypeCode": "381",
- "reversalScope": "full",
- "issueDate": "2019-08-24"
}
], - "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "documentTarget": "b2b",
- "seller": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "sellerIsKleinunternehmer": true,
- "isDauerrechnung": true,
- "dauerrechnungState": "active",
- "buyer": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyerReference": "string",
- "buyerEmail": "user@example.com",
- "notes": "string",
- "lineItems": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "description": "string",
- "quantity": "9542.50",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "lineNetAmount": "9542.50",
- "taxRatePercent": "9542.50",
- "itemDescription": "string",
- "vatCategory": "S",
- "vatExemptionReasonCode": "string",
- "vatExemptionReasonText": "string"
}
], - "totals": {
- "totalNet": "9542.50",
- "totalTax": "9542.50",
- "totalGross": "9542.50"
}, - "taxBreakdown": [
- {
- "taxRatePercent": "9542.50",
- "taxableAmount": "9542.50",
- "taxAmount": "9542.50"
}
], - "issuedAt": "2019-08-24T14:15:22Z",
- "processingRestricted": true,
- "processingRestrictionReason": "article_18_gdpr_request",
- "restrictedAt": "2019-08-24T14:15:22Z",
- "restrictedBy": "9f682db5-e5ef-4021-aef3-5e4a0ea64036",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Issue a draft (compliance gate). Runs the KoSIT gate synchronously: assigns the invoice number (sticky — reused on a retry after a gate failure), renders + validates both XRechnung and ZUGFeRD in parallel, and either commits (artifacts + report + email outbox) or rejects with actionable findings. the validation gate in report-only mode (rollout default) never blocks — the commit runs regardless of verdict, with `validationState` recording the outcome; the validation gate in enforce mode blocks on any error-severity finding.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 200
- 402
- 409
- 422
- 503
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24",
- "status": "draft",
- "currency": "string",
- "buyerName": "string",
- "totalGross": "9542.50",
- "validationState": "failed",
- "correctionState": "cancelled",
- "transmissionState": "not_sent",
- "transmissionProblemReason": "hard_bounce",
- "transmissionLastEventAt": "2019-08-24T14:15:22Z",
- "mode": "live",
- "sendState": {
- "class": "none",
- "recordedAt": "2019-08-24T14:15:22Z",
- "assertedOn": "2019-08-24"
}, - "transmission": {
- "state": "not_sent",
- "heldReason": "tenant_paused",
- "problemReason": "hard_bounce",
- "lastEventAt": "2019-08-24T14:15:22Z",
- "sender": {
- "kind": "tenant_smtp",
- "fromAddress": "string",
- "replyTo": "string"
}, - "recipientSuppressed": true,
- "actions": [
- "correct_address"
]
}, - "capabilities": {
- "externalSendRecord": true,
- "adHocRecipient": true
}, - "dueDate": "2019-08-24",
- "paymentTerms": "string",
- "invoiceTypeCode": "380",
- "reversalScope": "full",
- "precedingInvoice": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24"
}, - "correctedBy": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "sourceOfferId": "27f452e8-4c83-478a-af96-74a338a701ab",
- "sourceOffer": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "offerNumber": "string"
}, - "correctionDocuments": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "invoiceTypeCode": "381",
- "reversalScope": "full",
- "issueDate": "2019-08-24"
}
], - "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "documentTarget": "b2b",
- "seller": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "sellerIsKleinunternehmer": true,
- "isDauerrechnung": true,
- "dauerrechnungState": "active",
- "buyer": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyerReference": "string",
- "buyerEmail": "user@example.com",
- "notes": "string",
- "lineItems": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "description": "string",
- "quantity": "9542.50",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "lineNetAmount": "9542.50",
- "taxRatePercent": "9542.50",
- "itemDescription": "string",
- "vatCategory": "S",
- "vatExemptionReasonCode": "string",
- "vatExemptionReasonText": "string"
}
], - "totals": {
- "totalNet": "9542.50",
- "totalTax": "9542.50",
- "totalGross": "9542.50"
}, - "taxBreakdown": [
- {
- "taxRatePercent": "9542.50",
- "taxableAmount": "9542.50",
- "taxAmount": "9542.50"
}
], - "issuedAt": "2019-08-24T14:15:22Z",
- "processingRestricted": true,
- "processingRestrictionReason": "article_18_gdpr_request",
- "restrictedAt": "2019-08-24T14:15:22Z",
- "restrictedBy": "9f682db5-e5ef-4021-aef3-5e4a0ea64036",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "validation": {
- "verdict": "pass",
- "reports": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "format": "xrechnung-cii",
- "formatVersion": "string",
- "artifactSetId": "string",
- "validatorName": "string",
- "validatorVersion": "string",
- "verdict": "pass",
- "kind": "issue",
- "findings": [
- {
- "ruleId": "BR-DE-15",
- "severity": "error",
- "message": "string",
- "location": "string"
}
], - "validatedAt": "2019-08-24T14:15:22Z"
}
]
}, - "section14": {
- "status": "passed",
- "rulesetVersion": "1.0.0",
- "evaluatedAt": "2019-08-24T14:15:22Z",
- "items": [
- {
- "code": "section14.abs4.nr6",
- "nr": 1,
- "label": "string",
- "status": "present",
- "severity": "warning",
- "exemptionRule": "string",
- "message": "string"
}
]
}, - "warnings": [
- {
- "ruleId": "BR-DE-15",
- "severity": "error",
- "message": "string",
- "location": "string"
}
]
}Derive a Stornorechnung (credit note, UNTDID 381) from an issued invoice
Creates and returns a new DRAFT credit note (invoiceTypeCode="381") that references the given issued/paid invoice via the EN 16931 Preceding Invoice Reference (BG-3). Line items are copied from the original with POSITIVE amounts — a credit note states positive figures; the credit direction is carried by the type code, never by sign — and totals/tax are recomputed server-side. The original invoice is never modified. A full credit is returned; a partial credit is produced by editing the returned draft before issuing. The returned draft carries reversalScope="full" and is titled „Stornorechnung" on the rendered document. Editing it so it no longer covers the whole original flips reversalScope to "partial" and the title to „Rechnungskorrektur", so the printed word always matches what the document actually reverses. invoiceTypeCode stays "381" either way — the scope is a label, not a second document type, and nothing about booking, dunning or the permitted preceding references changes with it. Auth/scope/idempotency are identical to POST /invoices (honors the optional Idempotency-Key header so a retried derive is a replay, not a second draft).
Authorizations:
path Parameters
| id required | string <uuid> The issued/paid invoice to credit. |
header Parameters
| Idempotency-Key | string <= 255 characters Optional client-supplied key that makes this mutating request safe to retry. The FIRST request runs and its outcome is recorded keyed by |
Responses
Production
Response samples
- 201
- 409
- 422
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24",
- "status": "draft",
- "currency": "string",
- "buyerName": "string",
- "totalGross": "9542.50",
- "validationState": "failed",
- "correctionState": "cancelled",
- "transmissionState": "not_sent",
- "transmissionProblemReason": "hard_bounce",
- "transmissionLastEventAt": "2019-08-24T14:15:22Z",
- "mode": "live",
- "sendState": {
- "class": "none",
- "recordedAt": "2019-08-24T14:15:22Z",
- "assertedOn": "2019-08-24"
}, - "transmission": {
- "state": "not_sent",
- "heldReason": "tenant_paused",
- "problemReason": "hard_bounce",
- "lastEventAt": "2019-08-24T14:15:22Z",
- "sender": {
- "kind": "tenant_smtp",
- "fromAddress": "string",
- "replyTo": "string"
}, - "recipientSuppressed": true,
- "actions": [
- "correct_address"
]
}, - "capabilities": {
- "externalSendRecord": true,
- "adHocRecipient": true
}, - "dueDate": "2019-08-24",
- "paymentTerms": "string",
- "invoiceTypeCode": "380",
- "reversalScope": "full",
- "precedingInvoice": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24"
}, - "correctedBy": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "sourceOfferId": "27f452e8-4c83-478a-af96-74a338a701ab",
- "sourceOffer": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "offerNumber": "string"
}, - "correctionDocuments": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "invoiceTypeCode": "381",
- "reversalScope": "full",
- "issueDate": "2019-08-24"
}
], - "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "documentTarget": "b2b",
- "seller": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "sellerIsKleinunternehmer": true,
- "isDauerrechnung": true,
- "dauerrechnungState": "active",
- "buyer": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyerReference": "string",
- "buyerEmail": "user@example.com",
- "notes": "string",
- "lineItems": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "description": "string",
- "quantity": "9542.50",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "lineNetAmount": "9542.50",
- "taxRatePercent": "9542.50",
- "itemDescription": "string",
- "vatCategory": "S",
- "vatExemptionReasonCode": "string",
- "vatExemptionReasonText": "string"
}
], - "totals": {
- "totalNet": "9542.50",
- "totalTax": "9542.50",
- "totalGross": "9542.50"
}, - "taxBreakdown": [
- {
- "taxRatePercent": "9542.50",
- "taxableAmount": "9542.50",
- "taxAmount": "9542.50"
}
], - "issuedAt": "2019-08-24T14:15:22Z",
- "processingRestricted": true,
- "processingRestrictionReason": "article_18_gdpr_request",
- "restrictedAt": "2019-08-24T14:15:22Z",
- "restrictedBy": "9f682db5-e5ef-4021-aef3-5e4a0ea64036",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Dry-run preflight (compliance gate). Draft or scheduled only. Runs the same local preflight + parallel render+validate calls as `PATCH /invoices/{id}/finalize`, but never assigns a number (a placeholder BT-1 is used when the invoice is unnumbered) and never commits — no state change on the invoice, regardless of verdict. The report is stored with `kind=preflight`.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 200
- 409
- 422
- 503
{- "verdict": "pass",
- "reports": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "format": "xrechnung-cii",
- "formatVersion": "string",
- "artifactSetId": "string",
- "validatorName": "string",
- "validatorVersion": "string",
- "verdict": "pass",
- "kind": "issue",
- "findings": [
- {
- "ruleId": "BR-DE-15",
- "severity": "error",
- "message": "string",
- "location": "string"
}
], - "validatedAt": "2019-08-24T14:15:22Z"
}
], - "section14": {
- "status": "passed",
- "rulesetVersion": "1.0.0",
- "evaluatedAt": "2019-08-24T14:15:22Z",
- "items": [
- {
- "code": "section14.abs4.nr6",
- "nr": 1,
- "label": "string",
- "status": "present",
- "severity": "warning",
- "exemptionRule": "string",
- "message": "string"
}
]
}, - "warnings": [
- {
- "ruleId": "BR-DE-15",
- "severity": "error",
- "message": "string",
- "location": "string"
}
]
}Stateless Validation-as-a-service. Runs a submitted invoice through the SAME KoSIT + §14 compliance gate and returns the report — persisting NOTHING (no invoice, no stored report, no numbering, no metering). Requires the `validation:run` scope. Mounted only where standalone validation is enabled (off by default → 404).
Two request shapes, one verdict contract: (a) an invoice BODY (application/json, same shape as POST /invoices) is assembled in-memory (totals computed server-side, never trusted from input) and run through the api's KoSIT + §14 engine; (b) a SUBMITTED structured EN 16931 XML (application/xml) is proxied to zugferd's validate-only endpoint (no §14 — that reads an assembled model, not raw XML). The verdict is the SAME one issuance would gate (same engine) — it can never diverge.
200-with-findings (D-3): a fail verdict — KoSIT, §14, OR the local field-completeness preflight — is ordinary 200 response DATA, never an HTTP error. This NARROWLY diverges from the landed POST /invoices/{id}/validate dry-run, which throws 422 on its local preflight; this endpoint folds even that into the 200 findings. The two 422 boundaries are deliberately NOT reconciled (the dry-run's pre-flight-422 stays for its shipped consumers). 4xx here is reserved for a body that cannot be ASSEMBLED at all (path a) or an unknown formatVersion (path b).
Stateless fence: a submitted document is validated and forgotten — there is no landing zone, no field-extraction into invoices, no inbound receiving. Version is explicit (?profile=/?formatVersion= for path b; the api default for path a) and echoed as profile so the verdict is self-describing.
Authorizations:
query Parameters
| profile | string Example: profile=3.0.2 Path (b) only — the XRechnung/EN 16931 profile version to validate the submitted XML against (alias |
Request Body schema: required
| currency required | string ISO 4217 (3 letters) |
| invoiceTypeCode | string UNTDID 1001 (BT-3). Defaults to "380" if omitted. Allowlist- checked against |
object EN 16931 BG-3 reference. REQUIRED when invoiceTypeCode="384" (correction); a missing, dangling, draft/scheduled, or otherwise unresolvable reference is a 422 | |
| issueDate | string <date> On a create, this defaults to today. On the replace of a draft, omitting it KEEPS the date already stored — it is one of three properties on this body that are kept rather than cleared, which is why each of them says so here. |
| dueDate | string or null <date> EN 16931 BT-9 payment due date. Optional; when omitted the render clients fall back to issueDate + 30 days. Frozen at issue by the 0058/0059 immutability trigger. |
| paymentTerms | string or null EN 16931 BT-20 free-text payment terms / Zahlungsziel. Optional; frozen at issue. |
| customerId | string <uuid> Optional link to a saved customer; buyer is still snapshotted |
| documentTarget | string Enum: "b2b" "b2g" Optional per-invoice override of whether the recipient is a public authority. Omitting it does NOT clear it: the server resolves the linked customer's setting, then this draft's own stored value, so a replace that does not mention it keeps what the document already says. Snapshotted onto the invoice and frozen at issue. Note that the same property behaves differently on the customer replace, where omitting it sets 'b2b'. |
| status | string Enum: "draft" "scheduled" Desired initial status; defaults to "draft" when omitted. "issued" is NOT accepted — issued documents are reachable only via the finalize / internal-issue endpoints, which assign the invoice number and generate the artifacts. |
required | object (Party) Seller or buyer (EN 16931 BG-4 / BG-7, simplified). |
object Optional; defaults to the configured seller | |
| buyerReference | string |
| buyerElectronicAddress | string <= 200 characters EN 16931 BT-49 — the buyer's ELECTRONIC ROUTING address (a Leitweg-ID for B2G, or an email), used when generating XRechnung. Falls back to |
| title | string or null <= 500 characters Document title / Betreff, rendered above the line items. On a create, OMITTING the field applies the company-wide default title; an explicit |
| text | string or null <= 5000 characters Free introductory body text rendered above the line items. Distinct from |
| buyerEmail | string <email> Recipient email (prefilled from the customer) |
| notes | string or null <= 2000 characters Note / footer text rendered below the line items. On a create, OMITTING the field applies the company-wide default note; an explicit |
| invoiceNo | string Optional custom invoice number, used verbatim (uniqueness checked, counter NOT incremented). For a Dauerrechnung this is where the static, contract-derived Objektnummer goes (UStAE 14.5 Abs. 12), which satisfies §14 Abs. 4 Nr. 4. On a replace this property distinguishes the two cases explicitly: omitting it keeps the stored number, sending an empty string clears it. |
| billingPeriodStart | string <date> EN 16931 BT-73 — service/billing period start. For a Dauerrechnung this is the Leistungszeitraum "von" satisfying §14 Abs. 4 Nr. 6. |
| billingPeriodEnd | string <date> EN 16931 BT-74 — service/billing period end (Leistungszeitraum "bis"; leave unset for an open-ended continuous supply). |
| deliveryDate | string or null <date> EN 16931 BT-72 — actual delivery date (single-point Leistungsdatum) satisfying §14 Abs. 4 Nr. 6 for a one-off invoice whose service date differs from the issue date. Mutually exclusive with |
| isDauerrechnung | boolean Mark this invoice as a Dauerrechnung (continuous-supply invoice, UStAE 14.5 Abs. 17 self-identifying branch). Orthogonal to |
required | Array of objects (CreateInvoiceLineItem) non-empty |
Responses
Production
Request samples
- Payload
{- "currency": "string",
- "invoiceTypeCode": "380",
- "precedingInvoice": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string"
}, - "issueDate": "2019-08-24",
- "dueDate": "2019-08-24",
- "paymentTerms": "string",
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "documentTarget": "b2b",
- "status": "draft",
- "buyer": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "seller": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyerReference": "string",
- "buyerElectronicAddress": "string",
- "title": "string",
- "text": "string",
- "buyerEmail": "user@example.com",
- "notes": "string",
- "invoiceNo": "string",
- "billingPeriodStart": "2019-08-24",
- "billingPeriodEnd": "2019-08-24",
- "deliveryDate": "2019-08-24",
- "isDauerrechnung": true,
- "lineItems": [
- {
- "description": "string",
- "quantity": "0.25",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "taxRatePercent": "19",
- "itemDescription": "string",
- "vatCategory": "S"
}
]
}Response samples
- 200
- 400
- 403
- 503
{- "verdict": "pass",
- "reports": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "format": "xrechnung-cii",
- "formatVersion": "string",
- "artifactSetId": "string",
- "validatorName": "string",
- "validatorVersion": "string",
- "verdict": "pass",
- "kind": "issue",
- "findings": [
- {
- "ruleId": "BR-DE-15",
- "severity": "error",
- "message": "string",
- "location": "string"
}
], - "validatedAt": "2019-08-24T14:15:22Z"
}
], - "profile": "string",
- "section14": {
- "status": "passed",
- "rulesetVersion": "1.0.0",
- "evaluatedAt": "2019-08-24T14:15:22Z",
- "items": [
- {
- "code": "section14.abs4.nr6",
- "nr": 1,
- "label": "string",
- "status": "present",
- "severity": "warning",
- "exemptionRule": "string",
- "message": "string"
}
]
}, - "warnings": [
- {
- "ruleId": "BR-DE-15",
- "severity": "error",
- "message": "string",
- "location": "string"
}
]
}List validation report metadata for an invoice: every attempt (issue attempts, successful issuance, preflight dry-runs), verdict, kind, artifact/validator versions, and findings. Byte-exact report XML is not included — see `GET /invoices/{id}/validation-reports/{reportId}`.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 200
[- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "format": "xrechnung-cii",
- "formatVersion": "string",
- "artifactSetId": "string",
- "validatorName": "string",
- "validatorVersion": "string",
- "verdict": "pass",
- "kind": "issue",
- "findings": [
- {
- "ruleId": "BR-DE-15",
- "severity": "error",
- "message": "string",
- "location": "string"
}
], - "validatedAt": "2019-08-24T14:15:22Z"
}
]Retrieve a stored Prüfbericht (native KoSIT XML, byte-exact). 404 (not the report) if `reportId` belongs to a different invoice than `id` — no cross-invoice leakage.
Authorizations:
path Parameters
| id required | string <uuid> |
| reportId required | string <uuid> |
Responses
Production
The typed §14 Abs. 4 UStG Pflichtangaben result for an invoice — the clean three-state `Section14Summary` the frontends bind to (the raw `kind='section14'` row also remains in the validation-reports list). For an issued document the stored result is returned (`evaluatedAt` = when it was computed); a lost post-commit persist is transparently recomputed over the immutable issued document and re-persisted (recompute-on-read), so the result is always retrievable. Advisory-only: `status` is `passed`/`has_warnings` (or `unavailable`) — NEVER `failed`; a `not_applicable` item is never counted as a warning. RLS-scoped — a cross-tenant request 404s (indistinguishable from missing). PII-free.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 200
{- "status": "passed",
- "rulesetVersion": "1.0.0",
- "evaluatedAt": "2019-08-24T14:15:22Z",
- "items": [
- {
- "code": "section14.abs4.nr6",
- "nr": 1,
- "label": "string",
- "status": "present",
- "severity": "warning",
- "exemptionRule": "string",
- "message": "string"
}
]
}Manually (re-)send an issued invoice's delivery email
The body is OPTIONAL and a request without one behaves exactly as it always has. In ONE transaction append an immutable immutable send record + RE-ARM the single an outbound-mail record of kind invoice (sent_at=NULL, status='pending') so the mail dispatcher re-sends the ALREADY-ISSUED ZUGFeRD PDF. The invoice row is byte-UNTOUCHED (0058 immutability): no render, no re-issue. A deliberate re-send (fresh Idempotency-Key) is allowed and writes a NEW send record + re-arms; a raced double-submit with the same key is a replay of the 201. Refuses (writing nothing) for a non-issued/paid status (409 invoice_not_sendable), no recipient (422 no_recipient_email — buyer email ∅ and the linked customer's email ∅), or no SMTP configured (422 email_settings_not_configured). sendeprotokolle is left untouched.
Authorizations:
path Parameters
| id required | string <uuid> |
header Parameters
| Idempotency-Key | string |
Request Body schema: application/jsonoptional
| toEmail | string <email> Send this one message to an address you choose, instead of the address on file. It is used for this send only and is never written back to the customer or to the invoice, so the stored address stays as it was. |
| cc | Array of strings <email> <= 5 items [ items <email > ] Copy recipients, visible to everyone on the message. |
| bcc | Array of strings <email> <= 5 items [ items <email > ] Blind-copy recipients, invisible to the other recipients. They are recorded permanently alongside the send and cannot be removed afterwards. |
Responses
Production
Request samples
- Payload
{- "toEmail": "user@example.com",
- "cc": [
- "user@example.com"
], - "bcc": [
- "user@example.com"
]
}Response samples
- 201
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceId": "4f163819-178d-470c-a246-d6768476a6ec",
- "invoiceNumber": "string",
- "toEmail": "string",
- "subject": "string",
- "bodyHtml": "string",
- "trigger": "manual",
- "createdAt": "2019-08-24T14:15:22Z"
}The append-only send history of an invoice
Every (re-)send attempt of the invoice as an immutable send record (newest first) — both manual re-sends and the auto_issue delivery at issue. Drives the detail page's "Senden" vs "Erneut senden" label and the "zuletzt gesendet am" line.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 200
{- "data": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceId": "4f163819-178d-470c-a246-d6768476a6ec",
- "trigger": "manual",
- "evidence": "system_transmitted",
- "channel": "email",
- "toEmail": "string",
- "cc": [
- "string"
], - "bcc": [
- "string"
], - "sentOn": "2019-08-24",
- "note": "string",
- "reversesSendId": "06c0a322-ce3e-46f2-8307-b37bf5c41306",
- "retractionReason": "wrong_invoice",
- "retractedAt": "2019-08-24T14:15:22Z",
- "recordedBy": {
- "userId": "2c4a230c-5085-4924-a3e1-25fb4fc5965b",
- "name": "string",
- "status": "system"
}, - "createdAt": "2019-08-24T14:15:22Z"
}
]
}Record a dispatch you performed yourself
Records that a named person dispatched this invoice outside this application — by post, in person, or from their own mail client. It sends nothing and changes no document: no message is composed, no delivery is queued and the invoice is untouched.
The record is evidence of the STATEMENT, not of the dispatch. What it establishes is who said it, when they said it, and what they said. It establishes nothing about whether the document arrived.
Available to interactive sessions only. Programmatic credentials are refused, because the substance of the record is that a named person stated something.
Authorizations:
path Parameters
| id required | string <uuid> |
header Parameters
| Idempotency-Key | string <= 255 characters An optional client-generated key. A retry carrying the same key returns the recorded response instead of writing a second time. |
Request Body schema: application/jsonrequired
| channel required | string Enum: "post" "handover" "external_email" "other" How the document went out. |
| sentOn required | string <date> The calendar date you dispatched it. It may not be in the future and may not precede the invoice date. There is no limit on how long ago it was. |
| note | string <= 280 characters An optional internal remark. Never shown to the recipient and never part of any document. |
Responses
Production
Request samples
- Payload
{- "channel": "post",
- "sentOn": "2019-08-24",
- "note": "string"
}Response samples
- 201
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceId": "4f163819-178d-470c-a246-d6768476a6ec",
- "trigger": "manual",
- "evidence": "system_transmitted",
- "channel": "email",
- "toEmail": "string",
- "cc": [
- "string"
], - "bcc": [
- "string"
], - "sentOn": "2019-08-24",
- "note": "string",
- "reversesSendId": "06c0a322-ce3e-46f2-8307-b37bf5c41306",
- "retractionReason": "wrong_invoice",
- "retractedAt": "2019-08-24T14:15:22Z",
- "recordedBy": {
- "userId": "2c4a230c-5085-4924-a3e1-25fb4fc5965b",
- "name": "string",
- "status": "system"
}, - "createdAt": "2019-08-24T14:15:22Z"
}Withdraw a dispatch record
Withdraws a dispatch record you entered. The original record is never edited and never removed: the withdrawal is written as its own entry beside it, and the history shows both.
Only a record you entered can be withdrawn. A dispatch this application actually performed cannot be — correct a message sent to the wrong address by sending it again to the right one.
Authorizations:
path Parameters
| id required | string <uuid> |
| sendId required | string <uuid> |
header Parameters
| Idempotency-Key | string <= 255 characters An optional client-generated key. A retry carrying the same key returns the recorded response instead of writing a second time. |
Request Body schema: application/jsonrequired
| reason required | string Enum: "wrong_invoice" "not_actually_sent" "wrong_date" "duplicate_entry" "other" |
| note | string <= 280 characters |
Responses
Production
Request samples
- Payload
{- "reason": "wrong_invoice",
- "note": "string"
}Response samples
- 201
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceId": "4f163819-178d-470c-a246-d6768476a6ec",
- "trigger": "manual",
- "evidence": "system_transmitted",
- "channel": "email",
- "toEmail": "string",
- "cc": [
- "string"
], - "bcc": [
- "string"
], - "sentOn": "2019-08-24",
- "note": "string",
- "reversesSendId": "06c0a322-ce3e-46f2-8307-b37bf5c41306",
- "retractionReason": "wrong_invoice",
- "retractedAt": "2019-08-24T14:15:22Z",
- "recordedBy": {
- "userId": "2c4a230c-5085-4924-a3e1-25fb4fc5965b",
- "name": "string",
- "status": "system"
}, - "createdAt": "2019-08-24T14:15:22Z"
}Create an Angebot draft
Create a draft offer. Assigns the Angebotsnummer at creation from the mode-scoped, gap-permissible offer counter (NOT the invoice counter). Snapshots buyer + seller identity; totals are computed server-side via the SAME computeInvoice() engine invoices use (any totals in the body are ignored). Honors Idempotency-Key (a retried create is a replay). A readonly caller 403s via the scope.
Authorizations:
header Parameters
| Idempotency-Key | string |
Request Body schema: application/jsonrequired
| currency required | string ISO 4217 (3 letters) |
| customerId | string <uuid> Optional link to a saved customer; buyer is still snapshotted. |
required | object (Party) Seller or buyer (EN 16931 BG-4 / BG-7, simplified). |
object Omitted, the seller identity is taken from the company profile as it stands at the moment of the save. That applies to a replace as well, so send the seller you want recorded if it must not follow later changes to the company profile. | |
| buyerReference | string |
| buyerElectronicAddress | string |
| buyerEmail | string <email> |
| offerDate | string <date> Quote date. On a create it defaults to today. On a replace it is REQUIRED — see ReplaceOffer. |
| validUntil | string or null <date> "Gültig bis"; omit or null = no deadline ("ohne Frist"). |
| title | string or null Quote title / subject. On a create, omitting it takes the company-wide default title, while an explicit |
| text | string or null Body text. On a create, omitting it takes the company-wide default, while an explicit |
| notes | string or null Note. On a create, omitting it takes the company-wide default, while an explicit |
| bindingExclusionNote | string |
required | Array of objects (CreateOfferLineItem) non-empty |
Responses
Production
Request samples
- Payload
{- "currency": "string",
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "buyer": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "seller": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyerReference": "string",
- "buyerElectronicAddress": "string",
- "buyerEmail": "user@example.com",
- "offerDate": "2019-08-24",
- "validUntil": "2019-08-24",
- "title": "string",
- "text": "string",
- "notes": "string",
- "bindingExclusionNote": "string",
- "lineItems": [
- {
- "description": "string",
- "quantity": "0.25",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "taxRatePercent": "19"
}
]
}Response samples
- 201
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "offerNumber": "AN-00001",
- "status": "draft",
- "effectiveStatus": "draft",
- "offerDate": "2019-08-24",
- "validUntil": "2019-08-24",
- "daysUntilExpiry": 0,
- "currency": "string",
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "buyerName": "string",
- "totalGross": "9542.50",
- "convertedInvoiceId": "2b93c6ec-2326-4911-81b2-0489c5164cdf",
- "mode": "live",
- "seller": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyer": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyerReference": "string",
- "buyerElectronicAddress": "string",
- "buyerEmail": "string",
- "title": "string",
- "text": "string",
- "notes": "string",
- "bindingExclusionNote": "string",
- "totals": {
- "totalNet": "9542.50",
- "totalTax": "9542.50",
- "totalGross": "9542.50"
}, - "lineItems": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "lineNo": 0,
- "description": "string",
- "quantity": "9542.50",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "taxRatePercent": "9542.50",
- "lineNetAmount": "9542.50"
}
], - "convertedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}List Angebote (offset-paginated, derived expiry)
Offset-paginated list of the entity+mode-scoped offers, newest first (offer_date DESC, id DESC). Each row carries the DERIVED effectiveStatus (sent past validUntil → expired) and the signed daysUntilExpiry, computed server-side (DB clock) so web never diffs dates. status filters the STORED status; expired=1 filters the derived expired overlay.
Authorizations:
query Parameters
| page | integer >= 1 Default: 1 |
| pageSize | integer [ 1 .. 100 ] Default: 25 |
| status | string Enum: "draft" "sent" "accepted" "declined" Filter by STORED status ( |
| expired | string Enum: "0" "1" "true" "false" When truthy, return only the DERIVED expired set (sent + past validUntil). |
| customerId | string <uuid> Return only documents linked to this customer (exact match on the customer link). Documents created for an ad-hoc buyer carry no customer link and are therefore never returned when this filter is set. |
| q | string <= 100 characters Free-text filter, case-insensitive substring match over a CLOSED per-resource field set (invoices: invoice number + buyer name; offers: offer number + buyer name; customers: name + email). Trimmed; an empty/whitespace-only value is treated as absent, not as an error. Results are bounded by the pagination limit and by your own entity's scope. Deliberately NOT a fuzzy/ranked search — it is a plain substring match, and it does not search line items, notes or amounts. The term is never written to logs, audit records or webhook payloads. |
Responses
Production
Response samples
- 200
- 400
{- "data": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "offerNumber": "AN-00001",
- "status": "draft",
- "effectiveStatus": "draft",
- "offerDate": "2019-08-24",
- "validUntil": "2019-08-24",
- "daysUntilExpiry": 0,
- "currency": "string",
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "buyerName": "string",
- "totalGross": "9542.50",
- "convertedInvoiceId": "2b93c6ec-2326-4911-81b2-0489c5164cdf",
- "mode": "live"
}
], - "pagination": {
- "total": 847,
- "page": 3,
- "pageSize": 25,
- "totalPages": 34
}
}Preview the next Angebotsnummer
The Angebotsnummer a create would assign next, WITHOUT consuming the counter (the form preview; the GET /invoices/next-number sibling).
Authorizations:
Responses
Production
Response samples
- 200
{- "nextNumber": "AN-00001"
}Get an Angebot
Full detail: line items, computed totals, the derived effectiveStatus/daysUntilExpiry, and the convertedInvoiceId backlink. A missing / cross-scope id returns 404 (never leaks existence).
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "offerNumber": "AN-00001",
- "status": "draft",
- "effectiveStatus": "draft",
- "offerDate": "2019-08-24",
- "validUntil": "2019-08-24",
- "daysUntilExpiry": 0,
- "currency": "string",
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "buyerName": "string",
- "totalGross": "9542.50",
- "convertedInvoiceId": "2b93c6ec-2326-4911-81b2-0489c5164cdf",
- "mode": "live",
- "seller": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyer": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyerReference": "string",
- "buyerElectronicAddress": "string",
- "buyerEmail": "string",
- "title": "string",
- "text": "string",
- "notes": "string",
- "bindingExclusionNote": "string",
- "totals": {
- "totalNet": "9542.50",
- "totalTax": "9542.50",
- "totalGross": "9542.50"
}, - "lineItems": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "lineNo": 0,
- "description": "string",
- "quantity": "9542.50",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "taxRatePercent": "9542.50",
- "lineNetAmount": "9542.50"
}
], - "convertedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Replace a draft/sent Angebot's content
Full replace of the mutable fields of a draft/sent offer; totals are recomputed. Editability is an APPLICATION-level soft freeze (NOT the 0058 GoBD trigger): an accepted/declined or already-converted offer returns 409 offer_not_editable. offer_number/status/converted_* are never changed here.
Authorizations:
path Parameters
| id required | string <uuid> |
Request Body schema: application/jsonrequired
| currency required | string ISO 4217 (3 letters) |
| customerId | string <uuid> Optional link to a saved customer; buyer is still snapshotted. |
required | object (Party) Seller or buyer (EN 16931 BG-4 / BG-7, simplified). |
object Omitted, the seller identity is taken from the company profile as it stands at the moment of the save. That applies to a replace as well, so send the seller you want recorded if it must not follow later changes to the company profile. | |
| buyerReference | string |
| buyerElectronicAddress | string |
| buyerEmail | string <email> |
| offerDate required | string <date> Quote date. On a create it defaults to today. On a replace it is REQUIRED — see ReplaceOffer. |
| validUntil | string or null <date> "Gültig bis"; omit or null = no deadline ("ohne Frist"). |
| title | string or null Quote title / subject. On a create, omitting it takes the company-wide default title, while an explicit |
| text | string or null Body text. On a create, omitting it takes the company-wide default, while an explicit |
| notes | string or null Note. On a create, omitting it takes the company-wide default, while an explicit |
| bindingExclusionNote | string |
required | Array of objects (CreateOfferLineItem) non-empty |
Responses
Production
Request samples
- Payload
{- "currency": "string",
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "buyer": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "seller": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyerReference": "string",
- "buyerElectronicAddress": "string",
- "buyerEmail": "user@example.com",
- "offerDate": "2019-08-24",
- "validUntil": "2019-08-24",
- "title": "string",
- "text": "string",
- "notes": "string",
- "bindingExclusionNote": "string",
- "lineItems": [
- {
- "description": "string",
- "quantity": "0.25",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "taxRatePercent": "19"
}
]
}Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "offerNumber": "AN-00001",
- "status": "draft",
- "effectiveStatus": "draft",
- "offerDate": "2019-08-24",
- "validUntil": "2019-08-24",
- "daysUntilExpiry": 0,
- "currency": "string",
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "buyerName": "string",
- "totalGross": "9542.50",
- "convertedInvoiceId": "2b93c6ec-2326-4911-81b2-0489c5164cdf",
- "mode": "live",
- "seller": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyer": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyerReference": "string",
- "buyerElectronicAddress": "string",
- "buyerEmail": "string",
- "title": "string",
- "text": "string",
- "notes": "string",
- "bindingExclusionNote": "string",
- "totals": {
- "totalNet": "9542.50",
- "totalTax": "9542.50",
- "totalGross": "9542.50"
}, - "lineItems": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "lineNo": 0,
- "description": "string",
- "quantity": "9542.50",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "taxRatePercent": "9542.50",
- "lineNetAmount": "9542.50"
}
], - "convertedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Delete a draft Angebot
Delete a draft offer (leaves a numbering gap — fine; there is no gap-free obligation on a quote). A sent/accepted/declined offer is a communicated record and returns 409 offer_not_deletable.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Read the Angebotsnummer format
The Angebotsnummer prefix + zero-padding (from the company profile), defaulting to AN-/5 when no profile exists yet. A separate config from invoice numbering.
Authorizations:
Responses
Production
Response samples
- 200
{- "offerNumberPrefix": "AN-",
- "offerNumberPadding": 5
}Update the Angebotsnummer format (admin)
Set the Angebotsnummer prefix/padding. Requires the admin role (numbering config). The next GET /offers/next-number reflects the change. Never touches invoice numbering.
Authorizations:
Request Body schema: application/jsonrequired
| offerNumberPrefix required | string |
| offerNumberPadding required | integer [ 1 .. 12 ] |
Responses
Production
Request samples
- Payload
{- "offerNumberPrefix": "AN-",
- "offerNumberPadding": 5
}Response samples
- 200
{- "offerNumberPrefix": "AN-",
- "offerNumberPadding": 5
}Record a draft→sent transition
Record an offline/optional draft → sent transition. Idempotent from sent. Illegal from accepted/declined/converted → 409 offer_invalid_transition.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "offerNumber": "AN-00001",
- "status": "draft",
- "effectiveStatus": "draft",
- "offerDate": "2019-08-24",
- "validUntil": "2019-08-24",
- "daysUntilExpiry": 0,
- "currency": "string",
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "buyerName": "string",
- "totalGross": "9542.50",
- "convertedInvoiceId": "2b93c6ec-2326-4911-81b2-0489c5164cdf",
- "mode": "live",
- "seller": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyer": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyerReference": "string",
- "buyerElectronicAddress": "string",
- "buyerEmail": "string",
- "title": "string",
- "text": "string",
- "notes": "string",
- "bindingExclusionNote": "string",
- "totals": {
- "totalNet": "9542.50",
- "totalTax": "9542.50",
- "totalGross": "9542.50"
}, - "lineItems": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "lineNo": 0,
- "description": "string",
- "quantity": "9542.50",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "taxRatePercent": "9542.50",
- "lineNetAmount": "9542.50"
}
], - "convertedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Record the accepted outcome
Record the seller-observed accepted outcome. Idempotent + reversible (a mis-mark is corrected by re-marking). Legal from sent/accepted/declined; refused from draft or a converted offer → 409 offer_invalid_transition.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "offerNumber": "AN-00001",
- "status": "draft",
- "effectiveStatus": "draft",
- "offerDate": "2019-08-24",
- "validUntil": "2019-08-24",
- "daysUntilExpiry": 0,
- "currency": "string",
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "buyerName": "string",
- "totalGross": "9542.50",
- "convertedInvoiceId": "2b93c6ec-2326-4911-81b2-0489c5164cdf",
- "mode": "live",
- "seller": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyer": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyerReference": "string",
- "buyerElectronicAddress": "string",
- "buyerEmail": "string",
- "title": "string",
- "text": "string",
- "notes": "string",
- "bindingExclusionNote": "string",
- "totals": {
- "totalNet": "9542.50",
- "totalTax": "9542.50",
- "totalGross": "9542.50"
}, - "lineItems": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "lineNo": 0,
- "description": "string",
- "quantity": "9542.50",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "taxRatePercent": "9542.50",
- "lineNetAmount": "9542.50"
}
], - "convertedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Record the declined outcome
Record the seller-observed declined outcome. Idempotent + reversible. Refused from draft or a converted offer → 409 offer_invalid_transition.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "offerNumber": "AN-00001",
- "status": "draft",
- "effectiveStatus": "draft",
- "offerDate": "2019-08-24",
- "validUntil": "2019-08-24",
- "daysUntilExpiry": 0,
- "currency": "string",
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "buyerName": "string",
- "totalGross": "9542.50",
- "convertedInvoiceId": "2b93c6ec-2326-4911-81b2-0489c5164cdf",
- "mode": "live",
- "seller": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyer": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyerReference": "string",
- "buyerElectronicAddress": "string",
- "buyerEmail": "string",
- "title": "string",
- "text": "string",
- "notes": "string",
- "bindingExclusionNote": "string",
- "totals": {
- "totalNet": "9542.50",
- "totalTax": "9542.50",
- "totalGross": "9542.50"
}, - "lineItems": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "lineNo": 0,
- "description": "string",
- "quantity": "9542.50",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "taxRatePercent": "9542.50",
- "lineNetAmount": "9542.50"
}
], - "convertedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Convert an Angebot into a NEW invoice draft
Seeds and returns a NEW DRAFT invoice (invoiceTypeCode defaults to 380) from the offer's copied line items, buyer/seller snapshot, currency and texts, via the SAME prefill machinery as POST /invoices. Totals are recomputed server-side (equal to the offer's by construction). The offer is NOT consumed: it keeps its Angebotsnummer + lifecycle, is marked accepted/converted, and is backlinked to the derived invoice via an INTERNAL reference (source_offer_id) — NOT the EN 16931 preceding-invoice reference (a quote is not a preceding invoice). Converting an already-converted offer returns the existing invoice (200, idempotent). A draft source → 409.
Authorizations:
path Parameters
| id required | string <uuid> |
header Parameters
| Idempotency-Key | string |
Responses
Production
Response samples
- 200
- 201
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24",
- "status": "draft",
- "currency": "string",
- "buyerName": "string",
- "totalGross": "9542.50",
- "validationState": "failed",
- "correctionState": "cancelled",
- "transmissionState": "not_sent",
- "transmissionProblemReason": "hard_bounce",
- "transmissionLastEventAt": "2019-08-24T14:15:22Z",
- "mode": "live",
- "sendState": {
- "class": "none",
- "recordedAt": "2019-08-24T14:15:22Z",
- "assertedOn": "2019-08-24"
}, - "transmission": {
- "state": "not_sent",
- "heldReason": "tenant_paused",
- "problemReason": "hard_bounce",
- "lastEventAt": "2019-08-24T14:15:22Z",
- "sender": {
- "kind": "tenant_smtp",
- "fromAddress": "string",
- "replyTo": "string"
}, - "recipientSuppressed": true,
- "actions": [
- "correct_address"
]
}, - "capabilities": {
- "externalSendRecord": true,
- "adHocRecipient": true
}, - "dueDate": "2019-08-24",
- "paymentTerms": "string",
- "invoiceTypeCode": "380",
- "reversalScope": "full",
- "precedingInvoice": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24"
}, - "correctedBy": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "sourceOfferId": "27f452e8-4c83-478a-af96-74a338a701ab",
- "sourceOffer": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "offerNumber": "string"
}, - "correctionDocuments": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "invoiceTypeCode": "381",
- "reversalScope": "full",
- "issueDate": "2019-08-24"
}
], - "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "documentTarget": "b2b",
- "seller": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "sellerIsKleinunternehmer": true,
- "isDauerrechnung": true,
- "dauerrechnungState": "active",
- "buyer": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyerReference": "string",
- "buyerEmail": "user@example.com",
- "notes": "string",
- "lineItems": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "description": "string",
- "quantity": "9542.50",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "lineNetAmount": "9542.50",
- "taxRatePercent": "9542.50",
- "itemDescription": "string",
- "vatCategory": "S",
- "vatExemptionReasonCode": "string",
- "vatExemptionReasonText": "string"
}
], - "totals": {
- "totalNet": "9542.50",
- "totalTax": "9542.50",
- "totalGross": "9542.50"
}, - "taxBreakdown": [
- {
- "taxRatePercent": "9542.50",
- "taxableAmount": "9542.50",
- "taxAmount": "9542.50"
}
], - "issuedAt": "2019-08-24T14:15:22Z",
- "processingRestricted": true,
- "processingRestrictionReason": "article_18_gdpr_request",
- "restrictedAt": "2019-08-24T14:15:22Z",
- "restrictedBy": "9f682db5-e5ef-4021-aef3-5e4a0ea64036",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Render the Angebot as a plain PDF
On-demand, non-persisted plain-business-document PDF of the CURRENT offer state (rendered as a plain business document — NO ZUGFeRD/XML, NO KoSIT/EN 16931 validation). Always correct because a quote is editable.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Send the Angebot as a plain-PDF email
Render + freeze the per-send artifact, then in ONE transaction append the immutable immutable offer-send record + enqueue the an outbound-mail record of kind offer row (fetch-and-attach; the mail dispatcher defers if the bytes are not yet landed) + flip a draft offer to sent. Refuses (writing nothing) for a terminal/converted offer (409), no recipient (422 no_recipient_email), or no SMTP configured (422); a render failure aborts before any write (502). Honors Idempotency-Key (double-submit backstop). A re-send writes a NEW immutable send row.
Authorizations:
path Parameters
| id required | string <uuid> |
header Parameters
| Idempotency-Key | string |
Responses
Production
Response samples
- 201
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "offerId": "42da58f8-9040-4cf5-98ce-bce9965cca0d",
- "offerNumber": "string",
- "toEmail": "string",
- "subject": "string",
- "bodyHtml": "string",
- "artifactKey": "string",
- "createdAt": "2019-08-24T14:15:22Z"
}List customers (master data), ordered by name (paginated)
Authorizations:
query Parameters
| page | integer >= 1 Default: 1 Page number (1-indexed). |
| pageSize | integer [ 1 .. 100 ] Default: 25 Number of items per page. |
| q | string <= 100 characters Free-text filter, case-insensitive substring match over a CLOSED per-resource field set (invoices: invoice number + buyer name; offers: offer number + buyer name; customers: name + email). Trimmed; an empty/whitespace-only value is treated as absent, not as an error. Results are bounded by the pagination limit and by your own entity's scope. Deliberately NOT a fuzzy/ranked search — it is a plain substring match, and it does not search line items, notes or amounts. The term is never written to logs, audit records or webhook payloads. |
Responses
Production
Response samples
- 200
- 400
{- "data": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "customerNumber": "string",
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "email": "user@example.com",
- "defaultBuyerReference": "string",
- "defaultElectronicAddress": "string",
- "documentTarget": "b2b",
- "restrictedDocumentCount": 0,
- "suppressedAddress": {
- "address": "string",
- "suppressedAt": "2019-08-24T14:15:22Z",
- "reason": "string"
}, - "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "mode": "live"
}
], - "pagination": {
- "total": 847,
- "page": 3,
- "pageSize": 25,
- "totalPages": 34
}
}Create a customer (customerNumber is assigned by the server)
Honors the optional Idempotency-Key header so a retried creation is a replay, not a duplicate.
Authorizations:
header Parameters
| Idempotency-Key | string <= 255 characters Optional client-supplied key that makes this mutating request safe to retry. The FIRST request runs and its outcome is recorded keyed by |
Request Body schema: application/jsonrequired
| name required | string |
| addressLine1 | string |
| postalCode | string |
| city | string |
| countryCode | string ISO 3166-1 alpha-2 (2 letters) |
| vatId | string |
string <email> Recipient email for invoice delivery | |
| defaultBuyerReference | string |
| defaultElectronicAddress | string <= 200 characters EN 16931 BT-49 default — a Leitweg-ID or an email, prefilled onto new invoices and subscriptions. Send the bare value; the scheme ( |
| documentTarget | string Enum: "b2b" "b2g" Whether this customer is a public authority (b2g) or an ordinary business (b2b). It decides whether their invoices are later produced in the format public authorities require. On a CREATE, omitting it records an ordinary business customer. On the REPLACE it is REQUIRED — see ReplaceCustomer. The same property is KEPT rather than reset on the invoice and recurring-template replaces, which is why it is described here per property. |
Responses
Production
Request samples
- Payload
{- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "email": "user@example.com",
- "defaultBuyerReference": "string",
- "defaultElectronicAddress": "string",
- "documentTarget": "b2b"
}Response samples
- 201
- 400
- 409
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "customerNumber": "string",
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "email": "user@example.com",
- "defaultBuyerReference": "string",
- "defaultElectronicAddress": "string",
- "documentTarget": "b2b",
- "restrictedDocumentCount": 0,
- "suppressedAddress": {
- "address": "string",
- "suppressedAt": "2019-08-24T14:15:22Z",
- "reason": "string"
}, - "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "mode": "live"
}Get a customer by id
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "customerNumber": "string",
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "email": "user@example.com",
- "defaultBuyerReference": "string",
- "defaultElectronicAddress": "string",
- "documentTarget": "b2b",
- "restrictedDocumentCount": 0,
- "suppressedAddress": {
- "address": "string",
- "suppressedAt": "2019-08-24T14:15:22Z",
- "reason": "string"
}, - "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "mode": "live"
}Update a customer (full replace of editable fields)
Authorizations:
path Parameters
| id required | string <uuid> |
Request Body schema: application/jsonrequired
| name required | string |
| addressLine1 | string |
| postalCode | string |
| city | string |
| countryCode | string ISO 3166-1 alpha-2 (2 letters) |
| vatId | string |
string <email> Recipient email for invoice delivery | |
| defaultBuyerReference | string |
| defaultElectronicAddress | string <= 200 characters EN 16931 BT-49 default — a Leitweg-ID or an email, prefilled onto new invoices and subscriptions. Send the bare value; the scheme ( |
| documentTarget required | string Enum: "b2b" "b2g" Whether this customer is a public authority (b2g) or an ordinary business (b2b). It decides whether their invoices are later produced in the format public authorities require. On a CREATE, omitting it records an ordinary business customer. On the REPLACE it is REQUIRED — see ReplaceCustomer. The same property is KEPT rather than reset on the invoice and recurring-template replaces, which is why it is described here per property. |
Responses
Production
Request samples
- Payload
{- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "email": "user@example.com",
- "defaultBuyerReference": "string",
- "defaultElectronicAddress": "string",
- "documentTarget": "b2b"
}Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "customerNumber": "string",
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "email": "user@example.com",
- "defaultBuyerReference": "string",
- "defaultElectronicAddress": "string",
- "documentTarget": "b2b",
- "restrictedDocumentCount": 0,
- "suppressedAddress": {
- "address": "string",
- "suppressedAt": "2019-08-24T14:15:22Z",
- "reason": "string"
}, - "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "mode": "live"
}This customer's COMPLETE set of position assignments (Zuordnungen)
The read counterpart of the whole-set PUT below, and the ONLY sanctioned source for that PUT's body.
COMPLETE BY CONTRACT. This response is never paginated and never truncated: there is no page/pageSize/cursor/limit parameter, and assignments.length IS the total. Should the stored set ever exceed the SAME ceiling the sibling PUT enforces on its body (ReplaceCustomerPositions.assignments.maxItems — one constant in code, not two numbers), this endpoint returns 409 assignment_set_too_large rather than a partial body. A client that checks nothing still cannot obtain a truncated set.
Why it exists (and see the PUT, whose earlier reasoning this supersedes): deriving the assignment set by filtering a page of GET /positions is unsafe for ANY page size, because the PUT is a whole-set replace and an unloaded assignment reads as a deletion.
Each entry embeds the position fields the Zuordnungen table renders (Bezeichnung, Katalogpreis, Einheit, Waehrung) so the screen needs no second lookup — but deliberately NOT the position's OTHER customers (Position.assignments): this is a read about ONE customer and must not return every other customer's name and price.
Ordered by the position's description (the catalog's own order), with positionId as the deterministic tiebreak. Entity- and mode-scoped.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 200
{- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "assignments": [
- {
- "positionId": "da3402dc-13f8-45f9-83a6-bde06dd8eb35",
- "description": "string",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "taxRatePercent": "9542.50",
- "currency": "string",
- "defaultQuantity": "9542.50",
- "unitPriceNetOverride": "9542.50"
}
]
}Replace this customer's COMPLETE set of position assignments (Zuordnungen)
Whole-set replace: the body IS the resulting assignment set. Any assignment not present in assignments is REMOVED. An empty array is valid and clears all of this customer's assignments.
PUT (not PATCH/POST) because the request describes the complete desired state of a set, which is exactly PUT's semantics. This shape is chosen deliberately over per-row create/update/delete calls: the customer edit page is a single table with a single "Zuordnungen speichern" button, so one atomic call means one validation path and no partial-failure states to design a UI for. The write runs in a transaction — it either applies completely or not at all.
It is scoped under the customer, not the position, because the assignment (and its price override, the "Kundenpreis") is a fact ABOUT THE CUSTOMER and is always read per customer.
SUPERSEDED 2026-08-09. This description used to state that there was deliberately NO GET counterpart, because "the customer edit page must load the full catalog anyway, so a dedicated GET would be a second shape for data the client necessarily already holds."
THE PREMISE WAS FALSE, AND ITS FALSENESS WAS THIS DEFECT. GET /positions is capped at pageSize 100 — a HARD maximum in the shared pagination schema, not a call-site choice — while this write accepts 500 assignments. So for any catalog above 100 entries the client did NOT hold the data: it silently omitted the unloaded assignments from this whole-set body, and this endpoint deleted them. No error, no warning, and the record read as if the user had removed them.
The read counterpart is GET /customers/{id}/positions, and it is COMPLETE BY CONTRACT — never paginated, never truncated, 409 rather than a partial body. Build this PUT's body from that read. Never from a page of GET /positions, at any page size.
Authorizations:
path Parameters
| id required | string <uuid> |
Request Body schema: application/jsonrequired
required | Array of objects <= 500 items |
Responses
Production
Request samples
- Payload
{- "assignments": [
- {
- "positionId": "da3402dc-13f8-45f9-83a6-bde06dd8eb35",
- "unitPriceNetOverride": "9542.50"
}
]
}Response samples
- 200
- 400
{- "assignments": [
- {
- "positionId": "da3402dc-13f8-45f9-83a6-bde06dd8eb35",
- "unitPriceNetOverride": "9542.50"
}
]
}List the position catalog (Positionsvorlagen), ordered by description (paginated)
Returns one PAGE of the catalog, each position carrying its customer assignments embedded (assignments). This is deliberate and is the endpoint's main design constraint: the invoice form must be able to resolve ANY customer's assigned positions and their effective unit price with ZERO extra network requests when the user changes the customer (the customer is chosen client-side inside an already-rendered form).
Effective price for a customer = assignments[].unitPriceNetOverride when that customer has an assignment with a non-null override, else the position's own unitPriceNet (the Katalogpreis).
assignments.length is also the "Kunden" count on the catalog list page — no separate aggregate endpoint is needed.
Assignments are scoped to the positions on the current page only. The underlying implementation uses 2 constant queries per request. Sized for the target scale (tens of positions).
Authorizations:
query Parameters
| page | integer >= 1 Default: 1 Page number (1-indexed). |
| pageSize | integer [ 1 .. 100 ] Default: 25 Number of items per page. |
Responses
Production
Response samples
- 200
- 400
{- "data": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "description": "string",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "taxRatePercent": "9542.50",
- "currency": "string",
- "defaultQuantity": "9542.50",
- "assignments": [
- {
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "customerNumber": "string",
- "customerName": "string",
- "unitPriceNetOverride": "9542.50"
}
], - "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "mode": "live"
}
], - "pagination": {
- "total": 847,
- "page": 3,
- "pageSize": 25,
- "totalPages": 34
}
}Create a position
Honors the optional Idempotency-Key header so a retried creation is a replay, not a duplicate.
Authorizations:
header Parameters
| Idempotency-Key | string <= 255 characters Optional client-supplied key that makes this mutating request safe to retry. The FIRST request runs and its outcome is recorded keyed by |
Request Body schema: application/jsonrequired
| description required | string [ 1 .. 500 ] characters |
| unitCode required | string [ 2 .. 3 ] characters ^[A-Z0-9]{2,3}$ EN 16931 BT-130 — the unit of measure this catalogue entry carries onto an invoice line. This is a CODED element: the value must come from the UN/ECE Recommendation 20 code list with the Recommendation 21 extension, as enforced by rule BR-CL-23, and NOT from your own vocabulary. Codes are UPPERCASE and are never corrected for you — "hur" is not "HUR". Common values: C62 (piece), HUR (hour), DAY, KGM (kilogram), MTR (metre), LTR (litre), MTK (square metre), KWH. The full accepted set is much wider than these examples; anything matching the published pattern and present in the code list is accepted. ANNOUNCED NARROWING: this endpoint currently ACCEPTS values outside the code list and records them, so an existing integration keeps working. Refusal is a separate, later change. Send a listed code now; the pattern above is what will be enforced. |
| unitPriceNet required | string (MoneyString) ^\d+(\.\d{1,2})?$ A monetary amount as a string, with at most two decimal places — the scale amounts are billed in. A finer value is refused rather than rounded: rounding it would change the number the caller sent, and because a unit price is rounded to whole cents before being multiplied by the quantity, that change is then multiplied too. A price of "12.999" at quantity 1000 billed 13000.00 against an honest 12999.00; "0.005" at the same quantity billed double. |
| taxRatePercent required | string (PercentString) ^\d+(\.\d{1})?$ A VAT rate as a string, with at most one decimal place — the scale the rendered document can carry. One decimal covers every German rate in use: the standard rates and the agricultural and forestry averages. A finer rate is refused rather than printed, because the document would then state a rate that does not produce the tax amount printed beside it. |
| currency | string = 3 characters ISO 4217. This price is copied onto invoice lines, so the currency is copied with it. On a CREATE, omitting it records EUR. On the REPLACE it is REQUIRED — see ReplacePosition. Until it became required, omitting it on a replace silently repriced a foreign-currency catalogue entry in euros. |
| defaultQuantity | string or null Standardmenge. Omit or send null for "not set" (client falls back to "1"). |
Responses
Production
Request samples
- Payload
{- "description": "string",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "taxRatePercent": "19",
- "currency": "str",
- "defaultQuantity": "9542.50"
}Response samples
- 201
- 400
- 409
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "description": "string",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "taxRatePercent": "9542.50",
- "currency": "string",
- "defaultQuantity": "9542.50",
- "assignments": [
- {
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "customerNumber": "string",
- "customerName": "string",
- "unitPriceNetOverride": "9542.50"
}
], - "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "mode": "live"
}Get a position by id (with its customer assignments)
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "description": "string",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "taxRatePercent": "9542.50",
- "currency": "string",
- "defaultQuantity": "9542.50",
- "assignments": [
- {
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "customerNumber": "string",
- "customerName": "string",
- "unitPriceNetOverride": "9542.50"
}
], - "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "mode": "live"
}Update a position (full replace of editable fields)
Does NOT affect any existing invoice or subscription template. Those hold SNAPSHOTS of the values they were created with, never a reference to this row. Assignments are not touched here — replace them via PUT /customers/{id}/positions.
Authorizations:
path Parameters
| id required | string <uuid> |
Request Body schema: application/jsonrequired
| description required | string [ 1 .. 500 ] characters |
| unitCode required | string [ 2 .. 3 ] characters ^[A-Z0-9]{2,3}$ EN 16931 BT-130 — the unit of measure this catalogue entry carries onto an invoice line. This is a CODED element: the value must come from the UN/ECE Recommendation 20 code list with the Recommendation 21 extension, as enforced by rule BR-CL-23, and NOT from your own vocabulary. Codes are UPPERCASE and are never corrected for you — "hur" is not "HUR". Common values: C62 (piece), HUR (hour), DAY, KGM (kilogram), MTR (metre), LTR (litre), MTK (square metre), KWH. The full accepted set is much wider than these examples; anything matching the published pattern and present in the code list is accepted. ANNOUNCED NARROWING: this endpoint currently ACCEPTS values outside the code list and records them, so an existing integration keeps working. Refusal is a separate, later change. Send a listed code now; the pattern above is what will be enforced. |
| unitPriceNet required | string (MoneyString) ^\d+(\.\d{1,2})?$ A monetary amount as a string, with at most two decimal places — the scale amounts are billed in. A finer value is refused rather than rounded: rounding it would change the number the caller sent, and because a unit price is rounded to whole cents before being multiplied by the quantity, that change is then multiplied too. A price of "12.999" at quantity 1000 billed 13000.00 against an honest 12999.00; "0.005" at the same quantity billed double. |
| taxRatePercent required | string (PercentString) ^\d+(\.\d{1})?$ A VAT rate as a string, with at most one decimal place — the scale the rendered document can carry. One decimal covers every German rate in use: the standard rates and the agricultural and forestry averages. A finer rate is refused rather than printed, because the document would then state a rate that does not produce the tax amount printed beside it. |
| currency required | string = 3 characters ISO 4217. This price is copied onto invoice lines, so the currency is copied with it. On a CREATE, omitting it records EUR. On the REPLACE it is REQUIRED — see ReplacePosition. Until it became required, omitting it on a replace silently repriced a foreign-currency catalogue entry in euros. |
| defaultQuantity | string or null Standardmenge. Omit or send null for "not set" (client falls back to "1"). |
Responses
Production
Request samples
- Payload
{- "description": "string",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "taxRatePercent": "19",
- "currency": "str",
- "defaultQuantity": "9542.50"
}Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "description": "string",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "taxRatePercent": "9542.50",
- "currency": "string",
- "defaultQuantity": "9542.50",
- "assignments": [
- {
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "customerNumber": "string",
- "customerName": "string",
- "unitPriceNetOverride": "9542.50"
}
], - "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "mode": "live"
}Delete a position (ALWAYS succeeds — never 409)
There is NO "in use" check and no 409 by design. Existing invoices and subscription templates snapshot their line items, so no document can reference this row and nothing can be broken by removing it. Only the customer assignments (and their price overrides) cascade away.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Per-customer open-items statement (Kontoauszug / Offene Posten)
For one customer, every OPEN invoice — status='issued' AND payment_state <> 'paid', INCLUDING not-yet-due invoices (the all-open sibling of GET /dunning/overdue, which returns only the overdue subset). Per row, server-computed (never re-derived in web): effective due date, openAmountMinor (= moneyToMinor(total_gross) − amount_paid_minor), a due/overdue flag + daysOverdue, the current Mahnstufe, and the dunning nextAction descriptor — plus customer-level rollup totals (open + overdue-only subtotal). Bounded (a single customer's open set is small), not cursor-paginated. Mode-scoped. DEGRADES GRACEFULLY where dunning is enabledis off:dunningEnabled=falseand thenextAction/currentLevel*fields arenull`, while open amounts + due/overdue are still returned (never a 404 on the flag).
Authorizations:
path Parameters
| id required | string <uuid> The customer whose open items are returned. |
Responses
Production
Response samples
- 200
{- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "dunningEnabled": true,
- "items": [
- {
- "invoiceId": "4f163819-178d-470c-a246-d6768476a6ec",
- "invoiceNumber": "string",
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "buyerName": "string",
- "currency": "string",
- "effectiveDueDate": "2019-08-24",
- "daysOverdue": 0,
- "overdue": true,
- "openAmountMinor": 0,
- "totalGrossMinor": 0,
- "currentLevelLabel": "string",
- "currentLevelSentAt": "2019-08-24T14:15:22Z",
- "nextAction": {
- "nextLevelKey": "string",
- "nextLevelLabel": "string",
- "sendable": true,
- "blockedReason": "cancelled_by_correction",
- "nextLevelAvailableFrom": "2019-08-24"
}
}
], - "totals": {
- "openAmountMinor": 0,
- "openCount": 0,
- "overdueAmountMinor": 0,
- "overdueCount": 0
}
}Read the company's recurring-billing defaults
The defaults used to pre-fill a new recurring template's cadence, billing timing, currency and trial length. They belong to the COMPANY that issues the documents — one set per company, identical in the sandbox and in the live world — and one company's values are never visible to another. A company that has never configured them reads the shipped defaults with isDefault: true; nothing is stored by a read. Open to any active member. Session-authenticated surfaces only — API keys are refused.
Authorizations:
Responses
Production
Response samples
- 200
{- "defaultCadence": "weekly",
- "defaultBillingTiming": "advance",
- "defaultCurrency": "string",
- "defaultTrialDays": 365,
- "isDefault": true,
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Store the company's recurring-billing defaults
Full replace of the four mutable default fields for the CALLING COMPANY only. All four are required — omitting one is refused rather than stored. Requires the administrator role; an ordinary or read-only member receives insufficient_role and nothing is written. Session-authenticated surfaces only — API keys are refused.
Authorizations:
Request Body schema: application/jsonrequired
| defaultCadence required | string Enum: "weekly" "monthly" "quarterly" "yearly" |
| defaultBillingTiming required | string Enum: "advance" "arrears" |
| defaultCurrency required | string = 3 characters |
| defaultTrialDays required | integer [ 0 .. 365 ] |
Responses
Production
Request samples
- Payload
{- "defaultCadence": "weekly",
- "defaultBillingTiming": "advance",
- "defaultCurrency": "str",
- "defaultTrialDays": 365
}Response samples
- 200
- 400
{- "defaultCadence": "weekly",
- "defaultBillingTiming": "advance",
- "defaultCurrency": "string",
- "defaultTrialDays": 365,
- "isDefault": true,
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}List subscription templates, ordered by creation date (paginated)
Authorizations:
query Parameters
| page | integer >= 1 Default: 1 Page number (1-indexed). |
| pageSize | integer [ 1 .. 100 ] Default: 25 Number of items per page. |
Responses
Production
Response samples
- 200
- 400
{- "data": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "buyerName": "string",
- "currency": "string",
- "cadence": "weekly",
- "billingTiming": "advance",
- "status": "active",
- "startsAt": "2019-08-24",
- "endsAt": "2019-08-24",
- "servicePeriodAnchor": "2019-08-24",
- "nextDueDate": "2019-08-24",
- "mode": "live"
}
], - "pagination": {
- "total": 847,
- "page": 3,
- "pageSize": 25,
- "totalPages": 34
}
}Create a subscription template
Honors the optional Idempotency-Key header so a retried creation is a replay, not a duplicate.
Authorizations:
header Parameters
| Idempotency-Key | string <= 255 characters Optional client-supplied key that makes this mutating request safe to retry. The FIRST request runs and its outcome is recorded keyed by |
Request Body schema: application/jsonrequired
| name required | string |
| customerId | string <uuid> |
| documentTarget | string Enum: "b2b" "b2g" Whether the recipient is a public authority. It decides the format every invoice this template generates is produced in. On a CREATE, omitting it takes the linked customer's setting, else an ordinary business. On the REPLACE it is REQUIRED — see ReplaceSubscription. Until it became required there, omitting it re-resolved the linked customer's setting, so re-linking a customer could change the format of every future invoice from this template without the property ever appearing in the request. |
| currency required | string ISO 4217 |
| cadence required | string Enum: "weekly" "monthly" "quarterly" "yearly" |
| billingTiming required | string Enum: "advance" "arrears" |
| startsAt required | string <date> |
| endsAt | string or null <date> |
| servicePeriodAnchor | string <date> Optional Leistungszeitraum (BT-73/74) anchor, decoupled from startsAt. When set, each generated invoice's service period is anchored to it and rolled by the Turnus; billing timing still governs when the invoice is issued. No proration — an offset first period still bills the full Turnus amount. Not applicable to weekly cadence; must sit within one Turnus of startsAt on either side. |
| buyerName required | string <= 200 characters |
| buyerAddressLine1 | string <= 200 characters |
| buyerPostalCode | string <= 20 characters |
| buyerCity | string <= 100 characters |
| buyerCountryCode | string = 2 characters ISO 3166-1 alpha-2; upper-cased server-side. |
| buyerVatId | string <= 40 characters USt-IdNr (BT-48) |
| buyerReference | string <= 200 characters |
| buyerElectronicAddress | string <= 200 characters EN 16931 BT-49 — the buyer's ELECTRONIC ROUTING address (a Leitweg-ID for B2G, or an email). Send the bare value: the scheme ( |
| buyerEmail | string <email> |
| sellerName | string <= 200 characters |
| sellerAddressLine1 | string <= 200 characters |
| sellerPostalCode | string <= 20 characters |
| sellerCity | string <= 100 characters |
| sellerCountryCode | string = 2 characters ISO 3166-1 alpha-2; upper-cased server-side. |
| sellerVatId | string <= 40 characters |
| sellerTaxNumber | string <= 100 characters |
| title | string <= 500 characters Document title / Betreff. |
| text | string <= 5000 characters Body text rendered above the line items ( |
| notes | string <= 2000 characters |
required | Array of objects (SubscriptionLineItem) non-empty |
Responses
Production
Request samples
- Payload
{- "name": "string",
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "documentTarget": "b2b",
- "currency": "string",
- "cadence": "weekly",
- "billingTiming": "advance",
- "startsAt": "2019-08-24",
- "endsAt": "2019-08-24",
- "servicePeriodAnchor": "2019-08-24",
- "buyerName": "string",
- "buyerAddressLine1": "string",
- "buyerPostalCode": "string",
- "buyerCity": "string",
- "buyerCountryCode": "st",
- "buyerVatId": "string",
- "buyerReference": "string",
- "buyerElectronicAddress": "string",
- "buyerEmail": "user@example.com",
- "sellerName": "string",
- "sellerAddressLine1": "string",
- "sellerPostalCode": "string",
- "sellerCity": "string",
- "sellerCountryCode": "st",
- "sellerVatId": "string",
- "sellerTaxNumber": "string",
- "title": "string",
- "text": "string",
- "notes": "string",
- "lineItems": [
- {
- "description": "string",
- "quantity": "0.25",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "taxRatePercent": "string"
}
]
}Response samples
- 201
- 400
- 409
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "buyerName": "string",
- "currency": "string",
- "cadence": "weekly",
- "billingTiming": "advance",
- "status": "active",
- "startsAt": "2019-08-24",
- "endsAt": "2019-08-24",
- "servicePeriodAnchor": "2019-08-24",
- "nextDueDate": "2019-08-24",
- "mode": "live",
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "documentTarget": "b2b",
- "buyerAddressLine1": "string",
- "buyerPostalCode": "string",
- "buyerCity": "string",
- "buyerCountryCode": "string",
- "buyerVatId": "string",
- "buyerReference": "string",
- "buyerElectronicAddress": "string",
- "buyerEmail": "user@example.com",
- "sellerName": "string",
- "notes": "string",
- "title": "string",
- "text": "string",
- "lineItems": [
- { }
], - "lastGeneratedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Get a subscription by id
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "buyerName": "string",
- "currency": "string",
- "cadence": "weekly",
- "billingTiming": "advance",
- "status": "active",
- "startsAt": "2019-08-24",
- "endsAt": "2019-08-24",
- "servicePeriodAnchor": "2019-08-24",
- "nextDueDate": "2019-08-24",
- "mode": "live",
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "documentTarget": "b2b",
- "buyerAddressLine1": "string",
- "buyerPostalCode": "string",
- "buyerCity": "string",
- "buyerCountryCode": "string",
- "buyerVatId": "string",
- "buyerReference": "string",
- "buyerElectronicAddress": "string",
- "buyerEmail": "user@example.com",
- "sellerName": "string",
- "notes": "string",
- "title": "string",
- "text": "string",
- "lineItems": [
- { }
], - "lastGeneratedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Update a subscription template (full replace)
Authorizations:
path Parameters
| id required | string <uuid> |
Request Body schema: application/jsonrequired
| name required | string |
| customerId | string <uuid> |
| documentTarget required | string Enum: "b2b" "b2g" Whether the recipient is a public authority. It decides the format every invoice this template generates is produced in. On a CREATE, omitting it takes the linked customer's setting, else an ordinary business. On the REPLACE it is REQUIRED — see ReplaceSubscription. Until it became required there, omitting it re-resolved the linked customer's setting, so re-linking a customer could change the format of every future invoice from this template without the property ever appearing in the request. |
| currency required | string ISO 4217 |
| cadence required | string Enum: "weekly" "monthly" "quarterly" "yearly" |
| billingTiming required | string Enum: "advance" "arrears" |
| startsAt required | string <date> |
| endsAt | string or null <date> |
| servicePeriodAnchor | string <date> Optional Leistungszeitraum (BT-73/74) anchor, decoupled from startsAt. When set, each generated invoice's service period is anchored to it and rolled by the Turnus; billing timing still governs when the invoice is issued. No proration — an offset first period still bills the full Turnus amount. Not applicable to weekly cadence; must sit within one Turnus of startsAt on either side. |
| buyerName required | string <= 200 characters |
| buyerAddressLine1 | string <= 200 characters |
| buyerPostalCode | string <= 20 characters |
| buyerCity | string <= 100 characters |
| buyerCountryCode | string = 2 characters ISO 3166-1 alpha-2; upper-cased server-side. |
| buyerVatId | string <= 40 characters USt-IdNr (BT-48) |
| buyerReference | string <= 200 characters |
| buyerElectronicAddress | string <= 200 characters EN 16931 BT-49 — the buyer's ELECTRONIC ROUTING address (a Leitweg-ID for B2G, or an email). Send the bare value: the scheme ( |
| buyerEmail | string <email> |
| sellerName | string <= 200 characters |
| sellerAddressLine1 | string <= 200 characters |
| sellerPostalCode | string <= 20 characters |
| sellerCity | string <= 100 characters |
| sellerCountryCode | string = 2 characters ISO 3166-1 alpha-2; upper-cased server-side. |
| sellerVatId | string <= 40 characters |
| sellerTaxNumber | string <= 100 characters |
| title | string <= 500 characters Document title / Betreff. |
| text | string <= 5000 characters Body text rendered above the line items ( |
| notes | string <= 2000 characters |
required | Array of objects (SubscriptionLineItem) non-empty |
Responses
Production
Request samples
- Payload
{- "name": "string",
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "documentTarget": "b2b",
- "currency": "string",
- "cadence": "weekly",
- "billingTiming": "advance",
- "startsAt": "2019-08-24",
- "endsAt": "2019-08-24",
- "servicePeriodAnchor": "2019-08-24",
- "buyerName": "string",
- "buyerAddressLine1": "string",
- "buyerPostalCode": "string",
- "buyerCity": "string",
- "buyerCountryCode": "st",
- "buyerVatId": "string",
- "buyerReference": "string",
- "buyerElectronicAddress": "string",
- "buyerEmail": "user@example.com",
- "sellerName": "string",
- "sellerAddressLine1": "string",
- "sellerPostalCode": "string",
- "sellerCity": "string",
- "sellerCountryCode": "st",
- "sellerVatId": "string",
- "sellerTaxNumber": "string",
- "title": "string",
- "text": "string",
- "notes": "string",
- "lineItems": [
- {
- "description": "string",
- "quantity": "0.25",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "taxRatePercent": "string"
}
]
}Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "buyerName": "string",
- "currency": "string",
- "cadence": "weekly",
- "billingTiming": "advance",
- "status": "active",
- "startsAt": "2019-08-24",
- "endsAt": "2019-08-24",
- "servicePeriodAnchor": "2019-08-24",
- "nextDueDate": "2019-08-24",
- "mode": "live",
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "documentTarget": "b2b",
- "buyerAddressLine1": "string",
- "buyerPostalCode": "string",
- "buyerCity": "string",
- "buyerCountryCode": "string",
- "buyerVatId": "string",
- "buyerReference": "string",
- "buyerElectronicAddress": "string",
- "buyerEmail": "user@example.com",
- "sellerName": "string",
- "notes": "string",
- "title": "string",
- "text": "string",
- "lineItems": [
- { }
], - "lastGeneratedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Update subscription status (active / paused / cancelled / ended)
Authorizations:
path Parameters
| id required | string <uuid> |
Request Body schema: application/jsonrequired
| status required | string Enum: "active" "paused" "cancelled" "ended" |
Responses
Production
Request samples
- Payload
{- "status": "active"
}Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "buyerName": "string",
- "currency": "string",
- "cadence": "weekly",
- "billingTiming": "advance",
- "status": "active",
- "startsAt": "2019-08-24",
- "endsAt": "2019-08-24",
- "servicePeriodAnchor": "2019-08-24",
- "nextDueDate": "2019-08-24",
- "mode": "live",
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "documentTarget": "b2b",
- "buyerAddressLine1": "string",
- "buyerPostalCode": "string",
- "buyerCity": "string",
- "buyerCountryCode": "string",
- "buyerVatId": "string",
- "buyerReference": "string",
- "buyerElectronicAddress": "string",
- "buyerEmail": "user@example.com",
- "sellerName": "string",
- "notes": "string",
- "title": "string",
- "text": "string",
- "lineItems": [
- { }
], - "lastGeneratedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}List invoices generated from this subscription
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 200
[- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24",
- "status": "draft",
- "currency": "string",
- "buyerName": "string",
- "totalGross": "9542.50",
- "validationState": "failed",
- "correctionState": "cancelled",
- "transmissionState": "not_sent",
- "transmissionProblemReason": "hard_bounce",
- "transmissionLastEventAt": "2019-08-24T14:15:22Z",
- "mode": "live"
}
]Manually trigger invoice generation from a subscription template
Honors the optional Idempotency-Key header so a retried trigger is a replay, not a second draft. This ad-hoc generate mints a draft with no natural idempotency (unlike the internal per-period billing run), so it is idempotency-wrapped like POST /invoices.
Authorizations:
path Parameters
| id required | string <uuid> |
header Parameters
| Idempotency-Key | string <= 255 characters Optional client-supplied key that makes this mutating request safe to retry. The FIRST request runs and its outcome is recorded keyed by |
Responses
Production
Response samples
- 201
- 409
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24",
- "status": "draft",
- "currency": "string",
- "buyerName": "string",
- "totalGross": "9542.50",
- "validationState": "failed",
- "correctionState": "cancelled",
- "transmissionState": "not_sent",
- "transmissionProblemReason": "hard_bounce",
- "transmissionLastEventAt": "2019-08-24T14:15:22Z",
- "mode": "live",
- "sendState": {
- "class": "none",
- "recordedAt": "2019-08-24T14:15:22Z",
- "assertedOn": "2019-08-24"
}, - "transmission": {
- "state": "not_sent",
- "heldReason": "tenant_paused",
- "problemReason": "hard_bounce",
- "lastEventAt": "2019-08-24T14:15:22Z",
- "sender": {
- "kind": "tenant_smtp",
- "fromAddress": "string",
- "replyTo": "string"
}, - "recipientSuppressed": true,
- "actions": [
- "correct_address"
]
}, - "capabilities": {
- "externalSendRecord": true,
- "adHocRecipient": true
}, - "dueDate": "2019-08-24",
- "paymentTerms": "string",
- "invoiceTypeCode": "380",
- "reversalScope": "full",
- "precedingInvoice": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "issueDate": "2019-08-24"
}, - "correctedBy": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "sourceOfferId": "27f452e8-4c83-478a-af96-74a338a701ab",
- "sourceOffer": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "offerNumber": "string"
}, - "correctionDocuments": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceNumber": "string",
- "invoiceTypeCode": "381",
- "reversalScope": "full",
- "issueDate": "2019-08-24"
}
], - "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "documentTarget": "b2b",
- "seller": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "sellerIsKleinunternehmer": true,
- "isDauerrechnung": true,
- "dauerrechnungState": "active",
- "buyer": {
- "name": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "taxNumber": "string"
}, - "buyerReference": "string",
- "buyerEmail": "user@example.com",
- "notes": "string",
- "lineItems": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "description": "string",
- "quantity": "9542.50",
- "unitCode": "C62",
- "unitPriceNet": "9542.50",
- "lineNetAmount": "9542.50",
- "taxRatePercent": "9542.50",
- "itemDescription": "string",
- "vatCategory": "S",
- "vatExemptionReasonCode": "string",
- "vatExemptionReasonText": "string"
}
], - "totals": {
- "totalNet": "9542.50",
- "totalTax": "9542.50",
- "totalGross": "9542.50"
}, - "taxBreakdown": [
- {
- "taxRatePercent": "9542.50",
- "taxableAmount": "9542.50",
- "taxAmount": "9542.50"
}
], - "issuedAt": "2019-08-24T14:15:22Z",
- "processingRestricted": true,
- "processingRestrictionReason": "article_18_gdpr_request",
- "restrictedAt": "2019-08-24T14:15:22Z",
- "restrictedBy": "9f682db5-e5ef-4021-aef3-5e4a0ea64036",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}List overdue invoices (cursor-paginated, derived)
The derived overdue set: status='issued' AND payment_state IN ('open','partially_paid') AND COALESCE(due_date, issue_date+30) < CURRENT_DATE. A paid/overpaid/void/draft/scheduled invoice never appears (the payment_state filter is load-bearing — it excludes overpaid, unlike the inbound open-invoices read). Per row: the derived daysOverdue, openAmountMinor (= moneyToMinor(total_gross) − amount_paid_minor), the current Mahnstufe (highest reminder level snapshot), and the SERVER-COMPUTED nextAction descriptor (which level is next, whether it is sendable, and why not) — so web never derives the schedule. Mode-scoped; keyset over (effectiveDueDate, id) ASC. Mounted only where dunning is enabled` (else 404).
Authorizations:
query Parameters
| cursor | string Opaque keyset cursor from a prior page's |
| limit | integer [ 1 .. 100 ] Default: 50 |
Responses
Production
Response samples
- 200
- 400
{- "data": [
- {
- "invoiceId": "4f163819-178d-470c-a246-d6768476a6ec",
- "invoiceNumber": "string",
- "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
- "buyerName": "string",
- "currency": "string",
- "effectiveDueDate": "2019-08-24",
- "daysOverdue": 0,
- "openAmountMinor": 0,
- "totalGrossMinor": 0,
- "currentLevelLabel": "string",
- "currentLevelSentAt": "2019-08-24T14:15:22Z",
- "nextAction": {
- "nextLevelKey": "string",
- "nextLevelLabel": "string",
- "sendable": true,
- "blockedReason": "cancelled_by_correction",
- "nextLevelAvailableFrom": "2019-08-24"
}
}
], - "nextCursor": "string"
}Get the entity's Mahnstufen policy
The resolved per-entity dunning policy: the stored row, else the SHIPPED DEFAULT (the 4-level Zahlungserinnerung → 1./2./3. Mahnung set) so the feature works out of the box. ENTITY-scoped (not mode-scoped — one policy, the same in live + test). The interest/fee fields are INACTIVE (OFF / null / 0). Any active member may read.
Authorizations:
Responses
Production
Response samples
- 200
{- "levels": [
- {
- "sequence": 0,
- "key": "string",
- "label": "string",
- "dayOffset": 0,
- "newDeadlineDays": 1,
- "subjectTemplate": "string",
- "bodyHtmlTemplate": "string"
}
], - "interestFeatureEnabled": true,
- "basiszinssatzBp": 0,
- "interestSpreadB2bBp": 0,
- "interestSpreadB2cBp": 0,
- "interestDayCountBasis": "string",
- "dunningFeeMinor": 0,
- "verzugspauschaleB2bMinor": 0,
- "updatedAt": "2019-08-24T14:15:22Z",
- "isDefault": true
}Replace the entity's reminder ladder (admin-gated)
Replaces the entity's reminder ladder (ON CONFLICT (entity_id)). Admin-gated (requireRole('admin') — a readonly caller 403s). Validation: levels ordered by strictly-increasing sequence, unique level keys, dayOffset non-negative and strictly increasing by sequence, newDeadlineDays ≥ 1.
levels is the only property of this body and it is REQUIRED, so there is no omission question here at all: no other stored value can be reached from this operation. Late-payment interest and dunning fees are set with PUT /dunning/policy/charges; the send-evidence requirement with PATCH /dunning/policy. Supplying an interest or fee property here is REFUSED by name (dunning_charges_not_on_replace) and nothing is written — it is never silently dropped, because that would report a change that did not happen.
Authorizations:
Request Body schema: application/jsonrequired
required | Array of objects (DunningLevel) non-empty |
Responses
Production
Request samples
- Payload
{- "levels": [
- {
- "sequence": 0,
- "key": "string",
- "label": "string",
- "dayOffset": 0,
- "newDeadlineDays": 1,
- "subjectTemplate": "string",
- "bodyHtmlTemplate": "string"
}
]
}Response samples
- 200
{- "levels": [
- {
- "sequence": 0,
- "key": "string",
- "label": "string",
- "dayOffset": 0,
- "newDeadlineDays": 1,
- "subjectTemplate": "string",
- "bodyHtmlTemplate": "string"
}
], - "interestFeatureEnabled": true,
- "basiszinssatzBp": 0,
- "interestSpreadB2bBp": 0,
- "interestSpreadB2cBp": 0,
- "interestDayCountBasis": "string",
- "dunningFeeMinor": 0,
- "verzugspauschaleB2bMinor": 0,
- "updatedAt": "2019-08-24T14:15:22Z",
- "isDefault": true
}Set which record of sending a reminder requires
Sets one setting and nothing else: which record of the invoice having gone out a reminder requires. Every other value on the policy is left exactly as it is — deliberately a separate operation from the whole policy replace, so saving reminder levels can never reset this rule without the caller intending it.
any — a dispatch you recorded yourself is enough. system_transmitted — only a dispatch this application performed counts.
Neither value permits a reminder for an invoice with no record of any kind. A reminder level, once sent, cannot be sent again.
Authorizations:
Request Body schema: application/jsonrequired
| requireSendEvidence required | string Enum: "any" "system_transmitted" |
Responses
Production
Request samples
- Payload
{- "requireSendEvidence": "any"
}Response samples
- 200
{- "levels": [
- {
- "sequence": 0,
- "key": "string",
- "label": "string",
- "dayOffset": 0,
- "newDeadlineDays": 1,
- "subjectTemplate": "string",
- "bodyHtmlTemplate": "string"
}
], - "interestFeatureEnabled": true,
- "basiszinssatzBp": 0,
- "interestSpreadB2bBp": 0,
- "interestSpreadB2cBp": 0,
- "interestDayCountBasis": "string",
- "dunningFeeMinor": 0,
- "verzugspauschaleB2bMinor": 0,
- "updatedAt": "2019-08-24T14:15:22Z",
- "isDefault": true
}Set the late-payment interest and fee configuration, and nothing else
Sets the entity's late-payment interest and dunning-fee configuration and nothing else. Every property is REQUIRED, so this body carries no omission question at all, and no other stored value can be reached from here — the reminder ladder and the send-evidence requirement are untouched. Unknown properties are refused.
Three properties are required-and-NULLABLE: null is a value you STATE (no rate is configured), and it is a different act from leaving the property out, which is refused.
The values are stored as passed and are NOT validated as active values; the shipped configuration is inactive (feature off, no rate, zero amounts) and this operation is the only way to move it. Admin-gated (requireRole('admin') — a readonly caller 403s). Creates the entity's policy row from the shipped default ladder if none exists yet, so the setting cannot silently fail to stick.
Authorizations:
Request Body schema: application/jsonrequired
| interestFeatureEnabled required | boolean Master switch for stating late-payment interest and fees on a reminder. While it is false, nothing is stated and no amount is charged, whatever the other properties hold. |
| basiszinssatzBp required | integer or null The § 247 BGB base rate in basis points, or null when none is configured. Null suppresses interest entirely — no rate is ever guessed. |
| interestSpreadB2bBp required | integer or null The § 288 Abs. 2 spread over the base rate for business debtors, in basis points, or null when none is configured. |
| interestSpreadB2cBp required | integer or null The § 288 Abs. 1 spread over the base rate for consumer debtors, in basis points, or null when none is configured. |
| interestDayCountBasis required | string or null <= 32 characters How days are counted when interest is derived (for example |
| dunningFeeMinor required | integer >= 0 The Mahngebühr in minor units. A money amount — it is stored exactly as stated and is suppressed together with interest while the master switch is off. |
| verzugspauschaleB2bMinor required | integer >= 0 The § 288 Abs. 5 late-payment lump sum for business debtors, in minor units. A money amount, stored exactly as stated. |
Responses
Production
Request samples
- Payload
{- "interestFeatureEnabled": true,
- "basiszinssatzBp": 0,
- "interestSpreadB2bBp": 0,
- "interestSpreadB2cBp": 0,
- "interestDayCountBasis": "string",
- "dunningFeeMinor": 0,
- "verzugspauschaleB2bMinor": 0
}Response samples
- 200
{- "levels": [
- {
- "sequence": 0,
- "key": "string",
- "label": "string",
- "dayOffset": 0,
- "newDeadlineDays": 1,
- "subjectTemplate": "string",
- "bodyHtmlTemplate": "string"
}
], - "interestFeatureEnabled": true,
- "basiszinssatzBp": 0,
- "interestSpreadB2bBp": 0,
- "interestSpreadB2cBp": 0,
- "interestDayCountBasis": "string",
- "dunningFeeMinor": 0,
- "verzugspauschaleB2bMinor": 0,
- "updatedAt": "2019-08-24T14:15:22Z",
- "isDefault": true
}The per-invoice reminder history / Mahnverlauf
The append-only reminder records for one invoice, oldest level first (ascending reminder level — reminders escalate contiguously). A PURE RLS-scoped read of the reminder facts; the invoice is NEVER mutated and the current Mahnstufe is not derived here (that is the highest reminder level on GET /dunning/overdue). METADATA-ONLY: the level, sent-at, channel, recipient, the facts stated (open amount, both deadlines) and the evidence — the rendered sent_subject/sent_body_html snapshot is retained on the row for evidence but deliberately NOT surfaced (GDPR, no over-exposure). A cross-scope or unknown invoice id, and an invoice with no reminders, both return an empty reminders array. Mounted only when dunning (else 404).
Authorizations:
path Parameters
| id required | string <uuid> The invoice whose Mahnverlauf to read. |
Responses
Production
Response samples
- 200
{- "invoiceId": "4f163819-178d-470c-a246-d6768476a6ec",
- "reminders": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "levelSequence": 0,
- "levelKey": "string",
- "levelLabel": "string",
- "channel": "email",
- "recipientEmail": "string",
- "invoiceNumber": "string",
- "effectiveDueDate": "2019-08-24",
- "daysOverdue": 0,
- "openAmountMinor": 0,
- "currency": "string",
- "newPaymentDeadline": "2019-08-24",
- "interestChargedMinor": 0,
- "feeChargedMinor": 0,
- "interestRateBp": 0,
- "interestDays": 0,
- "createdAt": "2019-08-24T14:15:22Z",
- "artifactAvailable": true
}
]
}Send the next-due Mahnstufe for an invoice
Sends the NEXT-DUE reminder level (the lowest the reminder level not yet sent whose dayOffset ≤ daysOverdue) in ONE transaction: it INSERTs the immutable immutable reminder record (snapshotting the level + the facts stated + the api-rendered subject/body) and INSERTs a an outbound-mail record of kind dunning the mail dispatcher sends. The invoice is NEVER mutated (a reminder references it; the 0058 guard holds). Exactly-once: the one record per invoice and level guard makes a duplicate same-level send roll back with no second email (→ 409 mahnstufe_already_sent). Honors the optional Idempotency-Key header (a client double-submit is a replay). Available only where dunning is enabled (else 404). The rendered subject/body are byte-identical to GET /invoices/{id}/reminders/preview for the same inputs (one shared render implementation — no drift).
Authorizations:
path Parameters
| id required | string <uuid> The overdue invoice to remind. |
header Parameters
| Idempotency-Key | string <= 255 characters Optional client-supplied key that makes this mutating request safe to retry. The FIRST request runs and its outcome is recorded keyed by |
Responses
Production
Response samples
- 201
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "invoiceId": "4f163819-178d-470c-a246-d6768476a6ec",
- "levelSequence": 0,
- "levelKey": "string",
- "levelLabel": "string",
- "invoiceNumber": "string",
- "effectiveDueDate": "2019-08-24",
- "daysOverdue": 0,
- "openAmountMinor": 0,
- "currency": "string",
- "newPaymentDeadline": "2019-08-24",
- "channel": "email",
- "toEmail": "string",
- "subject": "string",
- "bodyHtml": "string",
- "interestChargedMinor": 0,
- "feeChargedMinor": 0,
- "createdAt": "2019-08-24T14:15:22Z"
}Preview the next-due reminder (byte-faithful dry-run)
A READ-ONLY dry-run that renders the next-due level's subject + body for an invoice WITHOUT writing or sending anything (no reminder row, no outbox row). Byte-identical to what POST /invoices/{id}/reminders would persist for the same inputs — the same "single rendering implementation, no drift" principle as the PDF preview. Blocked states (cancelled_by_correction / not_sent / not_overdue / no_level_due / no_recipient / processing_restricted) surface as a calm blockedReason (a 200 body with null subject/body), never a hard error. Available only where dunning is enabled (else 404).
Authorizations:
path Parameters
| id required | string <uuid> The invoice to preview a reminder for. |
Responses
Production
Response samples
- 200
{- "levelKey": "string",
- "levelLabel": "string",
- "recipientEmail": "string",
- "openAmountMinor": 0,
- "currency": "string",
- "originalDueDate": "2019-08-24",
- "newDeadline": "2019-08-24",
- "daysOverdue": 0,
- "subject": "string",
- "bodyHtml": "string",
- "blockedReason": "cancelled_by_correction"
}Preview the next-due Mahnung as a PDF (byte-faithful)
A READ-ONLY render of the next-due Mahnstufe as a plain-letter Mahnung PDF (DIN 5008), streamed WITHOUT writing or sending anything (no reminder row, no outbox row, no object stored). Rendered via the SAME render-dunning client + copy/cost resolvers a send freezes, so the preview is byte-faithful to the artifact a send would persist. Unwatermarked. Mounted only when dunning AND dunning PDFs are enabled (else 404). A blocked state (not overdue / no level due / restricted) is a mapped 4xx; a renderer failure is 502.
Authorizations:
path Parameters
| id required | string <uuid> The invoice to preview the next-due Mahnung for. |
Responses
Production
Read the frozen per-reminder Mahnung letter
The CUSTOMER-FACING read of the FROZEN Mahnung PDF that was actually sent for a specific reminder (Mahnstufe) — not a re-render of the next-due preview. Requires documents:read; the {id} invoice is the authorization anchor: the {reminderId} must belong to that invoice under the caller's RLS scope. Streamed inline for the in-app Mahnverlauf viewer. Mounted only when dunning AND dunning PDFs are enabled (else 404). A reminder id belonging to another invoice/tenant, or a legacy email-only reminder with no frozen artifact, is a 404 (never leaks existence; the web shows no "Brief ansehen" affordance for such a row).
Authorizations:
path Parameters
| id required | string <uuid> The invoice the reminder belongs to (the authorization anchor). |
| reminderId required | string <uuid> The specific reminder (Mahnstufe) whose frozen letter to read. |
Responses
Production
List inbound sources for the active tenant/entity
Admin/owner only (requireTenantContext + requireRole('admin')). Secrets are never returned here — only the masked configPrefix.
Authorizations:
Responses
Production
Response samples
- 200
{- "data": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "mode": "live",
- "type": "payment_provider",
- "verificationMethod": "hmac",
- "configPrefix": "insec_a1b2c3d4",
- "subscribedTypes": [
- "string"
], - "publicSlug": "string",
- "inboundPath": "string",
- "status": "active",
- "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}
]
}Register an inbound source
Registers an external source we RECEIVE signed event notifications from and returns it WITH the opaque stable publicSlug + copyable inboundPath (/inbound/<slug>). For a signed source-type (hmac/shared_secret) the full verification secret is returned exactly once (retrievable later via /reveal-config). mode is fixed at creation to the caller's active context; a fetch_to_confirm source's fetchBaseUrl is SSRF-checked (an internal host ⇒ 400 ssrf_blocked). Honors the optional Idempotency-Key header (a retried registration is a replay, not a second source minting a second secret).
Authorizations:
header Parameters
| Idempotency-Key | string <= 255 characters Optional client-supplied key that makes this mutating request safe to retry. The FIRST request runs and its outcome is recorded keyed by |
Request Body schema: application/jsonrequired
| type required | string Enum: "payment_provider" "bank_finapi" "generic_hmac" |
| verificationMethod required | string Enum: "hmac" "fetch_to_confirm" "mtls" "ip_allowlist" "shared_secret" |
| subscribedTypes required | Array of strings non-empty Free-form event types (a new type is a new registered handler). |
| mode | string Enum: "live" "test" Optional; must equal the caller's active mode (else 400). Fixed at creation. |
| entityId | string <uuid> Optional; must equal the caller's active entity (else 400). |
| fetchBaseUrl | string Only for |
Responses
Production
Request samples
- Payload
{- "type": "payment_provider",
- "verificationMethod": "hmac",
- "subscribedTypes": [
- "string"
], - "mode": "live",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "fetchBaseUrl": "string"
}Response samples
- 201
- 409
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "mode": "live",
- "type": "payment_provider",
- "verificationMethod": "hmac",
- "configPrefix": "insec_a1b2c3d4",
- "subscribedTypes": [
- "string"
], - "publicSlug": "string",
- "inboundPath": "string",
- "status": "active",
- "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "secret": "insec_a1b2c3d4e5f6…"
}Get one inbound source (masked config)
Authorizations:
path Parameters
| id required | string <uuid> Inbound source id. |
Responses
Production
Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "mode": "live",
- "type": "payment_provider",
- "verificationMethod": "hmac",
- "configPrefix": "insec_a1b2c3d4",
- "subscribedTypes": [
- "string"
], - "publicSlug": "string",
- "inboundPath": "string",
- "status": "active",
- "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Update an inbound source (subscribedTypes / status)
mode, type and verificationMethod are NOT mutable — a body containing one (or any unknown field) is rejected 400. Audit-logged.
Authorizations:
path Parameters
| id required | string <uuid> Inbound source id. |
Request Body schema: application/jsonrequired
non-empty | subscribedTypes | Array of strings non-empty |
| status | string Enum: "active" "disabled" |
Responses
Production
Request samples
- Payload
{- "subscribedTypes": [
- "string"
], - "status": "active"
}Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "mode": "live",
- "type": "payment_provider",
- "verificationMethod": "hmac",
- "configPrefix": "insec_a1b2c3d4",
- "subscribedTypes": [
- "string"
], - "publicSlug": "string",
- "inboundPath": "string",
- "status": "active",
- "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Disable an inbound source (soft-delete)
Soft-delete: flips status='disabled' so the inbound-event log stays referentially intact. Audit-logged.
Authorizations:
path Parameters
| id required | string <uuid> Inbound source id. |
Responses
Production
Reveal the full verification config (audit-logged, repeatable)
Decrypts and returns the full plaintext verification config (the retrievable-secret discipline). Writes an audit-log entry on EVERY call.
Authorizations:
path Parameters
| id required | string <uuid> Inbound source id. |
Responses
Production
Response samples
- 200
{- "config": {
- "secret": "insec_a1b2c3d4…",
- "fetchBaseUrl": "string"
}
}Rotate the verification secret (signed source-types only)
Generates a new secret and returns it once; the old secret stops verifying immediately (no overlap). Only valid for a signed source-type (hmac/shared_secret); a fetch_to_confirm/mtls/ip_allowlist source has no rotatable secret (409 no_secret_to_rotate). Audit-logged.
Authorizations:
path Parameters
| id required | string <uuid> Inbound source id. |
Responses
Production
Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "mode": "live",
- "type": "payment_provider",
- "verificationMethod": "hmac",
- "configPrefix": "insec_a1b2c3d4",
- "subscribedTypes": [
- "string"
], - "publicSlug": "string",
- "inboundPath": "string",
- "status": "active",
- "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "secret": "insec_a1b2c3d4e5f6…"
}Testanfrage simulieren — sandbox-locked synthetic received-event
SANDBOX-LOCKED (test mode ONLY — a live key/session is 409 sandbox_test_mode_only before any work). Fires a server-side SIGNED synthetic received-event against a chosen TEST invoice and flows it through the REAL receiver pipeline (verify → dedup → match → suggest/auto), so an ISV watches the timeline without wiring their sender. Structurally incapable of touching a LIVE invoice: the source is resolved under the test context and the pipeline scopes candidates to mode='test'. Only signed source-types (hmac/shared_secret) can be simulated (else 400 unsupported_method). Poll GET /inbound-events/:id (returned eventId) for the resulting step timeline.
Authorizations:
path Parameters
| id required | string <uuid> Inbound source id. |
Request Body schema: application/jsonrequired
| invoiceNumber | string The target TEST invoice's Beleg-Nr. |
| amountMinor | integer The received amount in minor units. |
| currency | string = 3 characters Default: "EUR" |
| remittanceInfo | string Free-text Verwendungszweck. |
| structuredReference | string A structured reference (wins over invoiceNumber). |
| valueDate | string |
| debtorIban | string Hashed before storage (never stored raw). |
| dedupKey | string Optional idempotency key; synthesized if omitted. |
| type | string Default: "payment_received" |
Responses
Production
Request samples
- Payload
{- "invoiceNumber": "string",
- "amountMinor": 0,
- "currency": "EUR",
- "remittanceInfo": "string",
- "structuredReference": "string",
- "valueDate": "string",
- "debtorIban": "string",
- "dedupKey": "string",
- "type": "payment_received"
}Response samples
- 202
{- "eventId": "d6703cc8-9e79-415d-ac03-a4dc7f6ab43c",
- "outcome": "string",
- "slug": "string"
}Public signed inbound-event receiver (the security spine)
SIGNATURE-authed inbound receiver: the caller is the external source, NOT a user — an explicit carve-out of the default-deny gate, authenticated per the source's verification method (hmac: HMAC-SHA256 over {Webhook-Id}.{Webhook-Timestamp}.{raw body} via Webhook-Signature; shared_secret: the opaque slug + an X-Inbound-Token header). VERIFIES BEFORE any side-effect; logs the arrival to the append-only inbound-event log; dedups on the source and its deduplication key; dispatches ONLY a verified, parseable event to its typed handler. A forged/unsigned POST is logged rejected_* and NEVER dispatched — it can never flip an invoice. Response: 404 unknown/disabled slug · 401 bad/absent signature · 200 stale (stop retrying) / duplicate / processed. Mounted only when inbound webhooks; public exposure is a gated infrastructure step (until then the endpoint is not publicly reachable).
path Parameters
| slug required | string The opaque, stable per-source slug (from source registration). |
header Parameters
| Webhook-Id | string Stable per-message id, identical across every retry of the same message. It is an HMAC input, so it cannot be altered in transit; dedupe on it. ( |
| Webhook-Signature | string
|
| Webhook-Timestamp | string Unix seconds. It is INSIDE the signature, so a captured message cannot be re-stamped and replayed — reject anything outside your tolerance (we use 5 minutes). ( |
| X-Inbound-Token | string The shared token we assigned ( |
Request Body schema: application/jsonrequired
Source-native event notification (metadata-only; never a document).
Responses
Production
Request samples
- Payload
{ }List received inbound events (cursor-paginated, metadata-only)
Admin/owner only. Cursor-paginated, MODE-SCOPED (a test read never returns live events, and vice versa), filterable by type/outcome/signatureStatus/from/to. METADATA-ONLY: no raw payload; the matched invoice surfaces as its Beleg-Nr. only (never the customer name). Keyset over (received_at, id) DESC.
Authorizations:
query Parameters
| type | string |
| outcome | string One outcome, or a COMMA-SEPARATED SET of them, so a "problems" view is one request over the four |
| sourceId | string <uuid> Return only events received by this one source. |
| signatureStatus | string Enum: "valid" "invalid" "missing" "stale" |
| from | string <date-time> |
| to | string <date-time> |
| cursor | string Opaque keyset cursor from a prior page's |
| limit | integer [ 1 .. 100 ] Default: 50 |
Responses
Production
Response samples
- 200
- 400
{- "data": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "sourceId": "797f5a94-3689-4ac8-82fd-d749511ea2b2",
- "type": "string",
- "receivedAt": "2019-08-24T14:15:22Z",
- "signatureStatus": "valid",
- "verified": true,
- "outcome": "processed",
- "matchedInvoiceNumber": "string",
- "remittanceReference": "string"
}
], - "nextCursor": "string"
}One inbound event + its processing-step timeline (metadata-only)
The metadata-only processing-step timeline (Signatur geprüft → Ereignis erkannt → Duplikat geprüft → Beleg zugeordnet → Aktion ausgeführt). The matched invoice is shown as its Beleg-Nr. only (never the customer name); no raw payload is dumped. Mode-scoped (a foreign-mode id is 404).
Authorizations:
path Parameters
| id required | string <uuid> The inbound-event id. |
Responses
Production
Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "sourceId": "797f5a94-3689-4ac8-82fd-d749511ea2b2",
- "type": "string",
- "receivedAt": "2019-08-24T14:15:22Z",
- "signatureStatus": "valid",
- "verified": true,
- "outcome": "processed",
- "matchedInvoiceNumber": "string",
- "remittanceReference": "string",
- "matchedInvoiceId": "98aa7909-a541-4a56-beb4-d49cfefdf41b",
- "amountMinor": 0,
- "currency": "string",
- "valueDate": "2019-08-24",
- "remittanceInfo": "string",
- "summary": {
- "totalGrossMinor": 0,
- "amountPaidMinor": 0,
- "openAmountMinor": 0,
- "paymentState": "open"
}, - "timeline": [
- {
- "step": "string",
- "status": "ok",
- "detail": "string"
}
]
}Re-dispatch (replay) a logged inbound event
Admin-gated "Erneut verarbeiten": re-dispatch a LOGGED event through the handler registry (async 202; poll GET /inbound-events/:id for the updated timeline). Reconstructs the event from the retained metadata (the raw body is never stored). IDEMPOTENT + safe: a forged/unverified event is NEVER re-dispatched (stays rejected — the invariant under replay); an already-processed event is a no-op (no second payment/allocation); a re-drivable event (pending_confirmation/rejected_*) re-runs match→transition, reusing the provenance payment (no double booking).
Authorizations:
path Parameters
| id required | string <uuid> The inbound-event id to re-dispatch. |
Responses
Production
Response samples
- 202
{- "eventId": "d6703cc8-9e79-415d-ac03-a4dc7f6ab43c",
- "outcome": "string",
- "dispatched": true
}Human-confirm the suggested payment match (Zahlungsabgleich)
Admin-gated human-confirm flip for a pending_confirmation event (the matcher suggested it). In ONE scoped transaction: (1) NOTIFY-THEN-FETCH — re-confirms the authoritative amount/status against our source-of-truth (a mismatch/none aborts with NO flip, the event stays pending_confirmation); (2) allocates the payment + recomputes payment_state, flipping status issued → paid ONLY when the invoice is exactly paid (a partial stays issued + partially_paid); (3) stamps outcome='processed', writes an audit-log entry, and emits the two-sided outbound event (invoice.paid on a full flip, payment.partial on a partial, payment.reconciled on an overpayment) through the webhook delivery infra. Idempotent — confirming an already-processed event is a no-op. A verified suggestion still flips nothing without this human confirm + notify-then-fetch (the invariant's second layer). Admin/owner only.
Authorizations:
path Parameters
| id required | string <uuid> The inbound-event id (a |
Responses
Production
Response samples
- 200
{- "outcome": "processed",
- "invoiceId": "4f163819-178d-470c-a246-d6768476a6ec",
- "paymentState": "open",
- "statusFlipped": true,
- "emitted": "string",
- "alreadyProcessed": true
}Record a payment against an invoice
Appends a payment FACT to the invoice's sub-ledger and finalizes its derived state. This is a ledger entry, never a status write — there is no mark-paid verb anywhere in this API; „mark as paid" is a UI shape over this endpoint with the body {"mode":"settle_remaining"}, which carries no money value at all. status/paymentState/amountPaidMinor move only through the single shared recompute, so a bank feed and a human entry produce the identical transition and the identical webhook.
mode is REQUIRED and the body is a tagged union. An absent or unreadable intent is 400 payment_intent_required and allocates NOTHING: on an endpoint that moves money, an omitted field must never resolve to the most consequential value. settle_remaining allocates the invoice's whole open amount computed SERVER-side, so it cannot overpay by construction and no number crosses the wire. exact_amount allocates the integer number of minor units you state — the mode for an early-payment discount, a part payment, or a rounding difference, where the amount that arrived is not the amount that was invoiced.
The open amount nets credit notes, and both modes measure against the same figure. A €1 000 invoice carrying a €300 partial credit note has an open amount of €700 — that is what settle_remaining books, and that is what an exact_amount is compared against. It is the same derivation GET /invoices/{id}/payments publishes as summary.openAmountMinor, so the number you are shown, the number a refusal quotes and the number the server books can never disagree.
No allocation of 0 is ever inserted. "€0 arrived" is not a fact, and this ledger records facts — an already-settled invoice is 409 invoice_already_settled, never a zero-amount row. Concurrent requests are serialised by a SELECT … FOR UPDATE on the invoice, so the loser re-reads the winner's committed amount and lands that same 409.
Idempotency-Key is REQUIRED (400 idempotency_key_required when absent). Payments have no natural exactly-once guard — two genuine €50 payments on one invoice on one day are legal — so the server cannot tell a double-submit from a real second payment; only the key can. The digest covers the RESOLVED PATH, so one key reused against a different invoice is a different request, not a replay. ⚠ A 4xx consumes its Idempotency-Key: a corrected retry must carry a fresh one. Mint one key per intent, when the intent is formed, and re-mint it when the intent changes.
⚠ amountMinor is an INTEGER in MINOR UNITS, and a value that is not one is refused rather than repaired. 98000 is 980,00 EUR. A string ("98000"), a fractional number (980.5) or an amount typed with a decimal comma is 400 payment_amount_required, naming the field — because those are exactly the shapes a mangled money input arrives in, and a server that quietly rounds or rescales one writes the repair onto an append-only ledger that also drives dunning. Normalise the value where the person typed it. An amount of 0 or below is a different answer: 422 payment_amount_out_of_range.
An overpayment is possible but never accidental. An amountMinor above the open amount is 409 payment_exceeds_open_amount, whose details.openAmountMinor states the true remaining balance; repeat the request with allowOverpayment: true if the larger amount is what really arrived. An invoice with NO open amount left is 409 invoice_already_settled in both modes — that refusal is taken before the stated amount is consulted at all.
The published enum below is exactly the set of modes the server executes, and CI fails if publishing and executing ever drift apart.
Scope is payments:write, and it is now SETTLED. It shipped on a transitional documents:write grant, which no longer reaches this route: recording a payment EXTINGUISHES a receivable, which is a different authority from creating one, so a bookkeeping integration needs payments:write and nothing more. Scopes are additive — this route's requirement can be widened but will never be tightened again.
⭐ The payment event family is a SEQUENCE, not a set of terminal flags. invoice.paid may be delivered more than once for one invoice; it may be followed by payment.reversed; and that may be followed by invoice.paid again. Apply them in created_at order — not arrival order — and treat the latest created_at as current. Two events with different ids are two distinct occurrences even when type is identical; invoice.paid is not a latch, and the absence of a further event is not confirmation.
Authorizations:
path Parameters
| id required | string <uuid> The issued invoice the payment settles. |
header Parameters
| Idempotency-Key required | string <= 255 characters REQUIRED on this route. A replay (same key, same resolved path, same body, after the first completed) returns the recorded response verbatim without re-executing. A key reused with a different body or against a different invoice is |
Request Body schema: application/jsonrequired
| mode required | string Enum: "settle_remaining" "exact_amount" The stated intent. |
| amountMinor | integer >= 1
|
| allowOverpayment | boolean Default: false
|
| valueDate | string <date> The calendar date the money arrived ( |
Responses
Production
Request samples
- Payload
{- "mode": "exact_amount",
- "amountMinor": 98000,
- "valueDate": "2026-08-09"
}Response samples
- 201
- 400
{- "paymentId": "472e651e-5a1e-424d-8098-23858bf03ad7",
- "allocationId": "9ec871ce-3363-4de2-8f79-062881067628",
- "amountMinor": 0,
- "currency": "string",
- "valueDate": "2019-08-24",
- "origin": "manual",
- "statusFlipped": true,
- "invoice": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "status": "draft",
- "paymentState": "open",
- "amountPaidMinor": 0,
- "openAmountMinor": 0
}
}The per-invoice payment ledger
The invoice's payment sub-ledger plus a summary. This is not a convenience read. The reversal is addressed by allocationId, and an AR-staff member cannot get one from the audit log (owner-role-only and metadata-only) — only from here. Without this read the reversal endpoint is unreachable in practice.
entries is ONE ROW PER ALLOCATION, discriminated by kind. A reversal is its OWN entry (negative amountMinor), never collapsed into a flag on its target — the projection therefore is the ledger, and Σ entries[].amountMinor === summary.amountPaidMinor is an invariant a client can assert. Its target additionally carries reversed: true and reversalAllocationId, so an "undo" affordance belongs on rows where kind === 'payment' && !reversed.
summary.openAmountMinor is totalGross − amountPaid, derived by the SAME code path the open-items worklist uses — and it is the only sanctioned source for the amount a confirm step states before recording a payment. Never client-computed.
origin publishes both manual and inbound from day one even though this increment can only produce manual.
Scope is payments:read and is now settled; the transitional documents:read grant no longer reaches it. A readonly member still holds payments:read, so the ledger view the human path had before this change is unchanged.
Authorizations:
path Parameters
| id required | string <uuid> The invoice whose payment ledger to read. |
Responses
Production
Response samples
- 200
{- "summary": {
- "totalGrossMinor": 0,
- "amountPaidMinor": 0,
- "openAmountMinor": 0,
- "paymentState": "open"
}, - "entries": [
- {
- "allocationId": "9ec871ce-3363-4de2-8f79-062881067628",
- "paymentId": "472e651e-5a1e-424d-8098-23858bf03ad7",
- "kind": "payment",
- "amountMinor": 0,
- "valueDate": "2019-08-24",
- "recordedAt": "2019-08-24T14:15:22Z",
- "origin": "manual",
- "reversed": true,
- "reversalAllocationId": "bd2d0d0e-8ed9-4c27-aa85-dbc7a3e3ff07",
- "reversesAllocationId": "f5a8c48e-e13d-47de-a20c-3065f36b0f92",
- "reversalReason": "wrong_amount"
}
]
}Reverse a recorded payment allocation
Corrects a mis-recorded payment by appending a compensating negative allocation that points at the original. Payments and their allocations are append-only, enforced by the database AND RESTRICTIVE RLS, so an UPDATE or DELETE is impossible, not merely discouraged — the payment ledger corrects itself the same way the invoice ledger does, by a new entry that references the old one. The payments row is never touched: a receipt that turned out never to have arrived stays on the ledger with a NET ALLOCATION OF ZERO, which is how a real ledger records it.
Keyed on the ALLOCATION, not the payment, because one payment may carry several allocations (a Sammelüberweisung across invoices, or several against one). Source the allocationId from GET /invoices/{id}/payments. Reversing an inbound allocation is explicitly allowed — a bank feed can be wrong too, and a Rücklastschrift makes it routine.
The invoice un-pays. If the reversal takes the invoice back below fully paid, status moves paid → issued and it re-enters the open-items and dunning worklists at its full amount. Without that, the invoice would read paid while genuinely unpaid and be invisible to every worklist.
At most ONE reversal per allocation, ever, enforced by a UNIQUE index as well as by the pre-check — two reversals would drive amountPaidMinor negative and re-dun the invoice for more than it is worth. A reversal is not itself reversible: the honest expression of "un-reverse" is a new POST /invoices/{id}/payments, because un-reversing is arithmetically a fresh receipt carrying no evidence that money arrived.
reason.category is REQUIRED; note (≤ 280 chars) is optional and is NEVER rendered to a customer. reason.category is readable back on GET /invoices/{id}/payments; the free-text note is stored but is NOT currently returned by any read.
Idempotency-Key is REQUIRED, same contract as the write.
Emits payment.reversed, once per reversed allocation — so a second reversal on the same invoice emits its own event, and a re-payment after a reversal emits a further invoice.paid. Apply the payment family in created_at order and treat the latest created_at as current.
Scope is payments:write — the same settled requirement as the write it undoes. Whoever may record a payment may correct one; splitting the two would leave a caller able to book a receipt it cannot take back.
Authorizations:
path Parameters
| id required | string <uuid> The invoice the allocation belongs to. It is part of the lookup, not decoration. |
| allocationId required | string <uuid> The allocation to reverse, as returned by |
header Parameters
| Idempotency-Key required | string <= 255 characters REQUIRED on this route — same contract as |
Request Body schema: application/jsonrequired
required | object |
Responses
Production
Request samples
- Payload
{- "reason": {
- "category": "wrong_invoice",
- "note": "Zahlung gehörte zu RE-2026-0042"
}
}Response samples
- 201
{- "reversalAllocationId": "bd2d0d0e-8ed9-4c27-aa85-dbc7a3e3ff07",
- "reversedAllocationId": "282f7c60-e2df-4d6e-b923-35e80a6c5f82",
- "paymentId": "472e651e-5a1e-424d-8098-23858bf03ad7",
- "invoiceId": "4f163819-178d-470c-a246-d6768476a6ec",
- "amountMinor": 0,
- "statusFlipped": true,
- "invoice": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "status": "draft",
- "paymentState": "open",
- "amountPaidMinor": 0,
- "openAmountMinor": 0
}
}DATEV-Format Buchungsstapel (EXTF CSV) export of issued documents for a period
Read-only, deterministic, repeatable. Books documents by status IN (issued, paid, void) and issue_date in [from, to] — void documents ARE included (a void does not retroactively un-issue a document; its own economic reversal must itself exist as a separate 381/384 document, which then also exports). EUR-only and bounded to one fiscal year per request (see the 422 responses). Mapping-config gaps or an invalid Belegfeld 1 never produce a partial/misbooked file — they 422 with no bytes returned and nothing recorded as exported. An empty period still returns 200 with a valid empty Stapel (header + column row, zero data rows) and a log row with documentCount=0. Response headers never include caching directives beyond Cache-Control: no-store — the file contains complete customer PII + revenue data for the period.
Authorizations:
query Parameters
| from required | string <date> ISO date (YYYY-MM-DD), inclusive. Belegdatum source. |
| to required | string <date> ISO date (YYYY-MM-DD), inclusive. |
| mappingVersion | integer >= 0 Pinned DATEV mapping-config version. Defaults to latest stored, or the version-0 built-in SKR default if nothing is stored. |
Responses
Production
Response samples
- 200
- 400
- 422
charset=windows-1252; CRLF line endingsThe GoBD compliance dossier for a document: every archived object (xml_original, pdf_hybrid, validation_report and sendeprotokoll), ordered by kind. Metadata only, no bytes (the single-artifact download is the browse surface). RLS-scoped to the caller's entity/mode — a cross-entity request returns an empty dossier, never a leak. A not-yet-archived document (or one without a Sendeprotokoll yet) returns fewer objects, not an error.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 200
{- "documentId": "4704590c-004e-410d-adf7-acb7ca0a7052",
- "objects": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "documentId": "4704590c-004e-410d-adf7-acb7ca0a7052",
- "kind": "xml_original",
- "sha256": "string",
- "sizeBytes": 0,
- "contentType": "string",
- "retentionClass": "beleg_8y",
- "retainUntil": "2019-08-24",
- "legalHold": true,
- "locked": true,
- "nonWorm": true,
- "archivedAt": "2019-08-24T14:15:22Z"
}
]
}Browse a entity's archived documents (cursor-paginated)
The entity-scoped, metadata-only, cursor-paginated archive browse list. One row per DOCUMENT (grouped over its its archived objects), newest-DATED first (by issueDate/Rechnungsdatum), with artifact-kind chips and a lazily-derived integrityStatus. Distinct from the async the export jobs ZIP flow: browse is "one page now", export is "everything, verifiable offline, later". RLS-scoped to the caller's entity/mode; the path :id (the entity) must equal the resolved context entity (403 entity_mismatch). Reads are open to any active member — a pure read over WORM data (only deletion is dual-control-gated). integrityStatus derives from the verification job's audit trail: failed if any object has a verification_failed newer than the entity's latest scope-level verification_ok, ok if covered with no later failure, else unverified (drawer/lazy field — the clean verdict is scope-level, not per-row).
Authorizations:
path Parameters
| id required | string <uuid> The entity to browse. Must equal the caller's active entity. |
query Parameters
| cursor | string Opaque keyset cursor from a prior page's |
| limit | integer [ 1 .. 100 ] Default: 50 Page size (1–100, default 50). |
| kind | string Enum: "xml_original" "pdf_hybrid" "validation_report" "sendeprotokoll" "attachment" "signature" Narrow to documents having this artifact kind (chips then show only it). Closed enum — a typo is rejected, not silently empty. |
| from | string <date-time> Inclusive lower bound on the issue date / Rechnungsdatum ( |
| to | string <date-time> Inclusive upper bound on the issue date / Rechnungsdatum ( |
Responses
Production
Response samples
- 200
- 400
{- "data": [
- {
- "documentId": "4704590c-004e-410d-adf7-acb7ca0a7052",
- "documentNumber": "string",
- "documentType": "string",
- "archivalStatus": "archived",
- "issueDate": "2019-08-24",
- "issuedAt": "2019-08-24T14:15:22Z",
- "archivedAt": "2019-08-24T14:15:22Z",
- "artifactKinds": [
- "xml_original"
], - "integrityStatus": "ok",
- "retention": {
- "retainUntil": "2019-08-24",
- "legalHold": true,
- "retentionClass": "beleg_8y"
}
}
], - "nextCursor": "string",
- "pendingCount": 0
}Download one archived artifact (synchronous stream)
Streams exactly one archived object's bytes — the byte- identical original the archiver stored, from the ObjectStore under its stored object key. Not the async export path. RLS-scoped: a cross-entity / wrong-mode / absent artifact is a 404, indistinguishable from "not found" (no leak). Cache-Control: no-store (PII-dense content). No re-render, no read-path hash re-check.
Authorizations:
path Parameters
| id required | string <uuid> The entity. Must equal the caller's active entity. |
| documentId required | string <uuid> |
| kind required | string Enum: "xml_original" "pdf_hybrid" "validation_report" "sendeprotokoll" "attachment" "signature" |
Responses
Production
The entity's active pre-deletion purge request
Returns the entity's active pre_deletion_export job in the approval tail (export_delivered/approved_1/approved_2), or { data: null } if none is in flight. The path :id must equal the active entity.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 200
{- "data": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "mode": "live",
- "state": "requested",
- "purpose": "self_serve_export",
- "ready": true,
- "manifestSha256": "string",
- "deliveredAt": "2019-08-24T14:15:22Z",
- "lastError": "string",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}
}Create a purge request — approval
Approval #1 of the export-gated, dual-control deletion workflow. References a pre_deletion_export job that MUST be in export_delivered state (a premature request → 409 export_not_delivered — the anti-rubber-stamp export gate). On success the job moves export_delivered → approved_1, recording the requester as the first approver. Owner/admin only. Execution never happens here — only the retention-sweep worker executes, on approved_2.
Authorizations:
path Parameters
| id required | string <uuid> |
Request Body schema: application/jsonrequired
| exportJobId required | string <uuid> The delivered pre_deletion_export job to approve. |
Responses
Production
Request samples
- Payload
{- "exportJobId": "8a16feed-2e0d-4228-8fbb-a05bb4aba530"
}Response samples
- 201
- 409
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "mode": "live",
- "state": "requested",
- "purpose": "self_serve_export",
- "ready": true,
- "manifestSha256": "string",
- "deliveredAt": "2019-08-24T14:15:22Z",
- "lastError": "string",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Approve a purge request — approval
Approval #2 — the SECOND, DIFFERENT owner/admin's approval. A same-actor second approval is rejected with 409 dual_control_same_actor (the anti-rubber-stamp dual-control guarantee: approved_1_by != approved_2_by, enforced in the service AND defended in SQL). On success the job moves approved_1 → approved_2; the retention-sweep worker then executes it (deleting ONLY objects past retain_until with no legal_hold, and purging the entity). Owner/admin only.
Authorizations:
path Parameters
| id required | string <uuid> |
| jobId required | string <uuid> |
Responses
Production
Response samples
- 200
- 409
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "mode": "live",
- "state": "requested",
- "purpose": "self_serve_export",
- "ready": true,
- "manifestSha256": "string",
- "deliveredAt": "2019-08-24T14:15:22Z",
- "lastError": "string",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Request an async export of an entity's archive
Creates an export job and returns its id IMMEDIATELY (202 — genuinely async, NOT a synchronous download). A background worker builds a checksum-verified ZIP (original XML+PDF per document, validation reports, sendeprotokolle, audit trail JSONL+CSV) + a manifest.json whose sha256 set verifies every file OFFLINE, uploads both, and sets ready. Poll GET /export-jobs/:id; download GET /export-jobs/:id/download; then POST /export-jobs/:id/confirm-delivery with the manifest hash to flip to export_delivered. Entity/mode come from the resolved context; the path :id must equal the active entity (403 entity_mismatch). mode-scoped — a live export never bundles test (sandbox) data. Honors the optional Idempotency-Key header so a retried request is a replay, not a second build job.
Authorizations:
path Parameters
| id required | string <uuid> The entity to export. Must equal the caller's active entity. |
header Parameters
| Idempotency-Key | string <= 255 characters Optional client-supplied key that makes this mutating request safe to retry. The FIRST request runs and its outcome is recorded keyed by |
Request Body schema: application/jsonoptional
| purpose | string Default: "self_serve_export" Enum: "self_serve_export" "pre_deletion_export"
|
Responses
Production
Request samples
- Payload
{- "purpose": "self_serve_export"
}Response samples
- 202
- 409
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "mode": "live",
- "state": "requested",
- "purpose": "self_serve_export",
- "ready": true,
- "manifestSha256": "string",
- "deliveredAt": "2019-08-24T14:15:22Z",
- "lastError": "string",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Poll an export job's status
Returns the job's state + a ready flag (true once the bundle is built and downloadable). The client stepper polls this: requested → (export_pending, ready:false — building) → (export_pending, ready:true — downloadable) → export_delivered. RLS-scoped: a cross-entity/mode id is a 404.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "mode": "live",
- "state": "requested",
- "purpose": "self_serve_export",
- "ready": true,
- "manifestSha256": "string",
- "deliveredAt": "2019-08-24T14:15:22Z",
- "lastError": "string",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Download a built export bundle
Streams the built export ZIP (Cache-Control: no-store — PII-dense). 409 export_not_ready if the worker hasn't built it yet; 404 for a cross-entity/mode or unknown job.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Confirm a checksum-verified export download
Flips a built job to export_delivered ONLY if the posted manifestSha256 matches the stored manifest_sha256 — the concrete "delivered means checksum-verified download, not 'the job ran'" mechanism A WRONG hash is rejected 409 with state UNCHANGED (not a rubber stamp). This delivered state is the deletion gate.
Authorizations:
path Parameters
| id required | string <uuid> |
Request Body schema: application/jsonrequired
| manifestSha256 required | string^[0-9a-f]{64}$ The sha256 the client computed over the downloaded manifest.json. |
Responses
Production
Request samples
- Payload
{- "manifestSha256": "string"
}Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "mode": "live",
- "state": "requested",
- "purpose": "self_serve_export",
- "ready": true,
- "manifestSha256": "string",
- "deliveredAt": "2019-08-24T14:15:22Z",
- "lastError": "string",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Get company profile (seller master data and invoice settings)
Authorizations:
Responses
Production
Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "sellerName": "string",
- "sellerAddressLine1": "string",
- "sellerPostalCode": "string",
- "sellerCity": "string",
- "sellerCountryCode": "string",
- "sellerVatId": "string",
- "taxNumber": "string",
- "isKleinunternehmer": true,
- "paymentIban": "string",
- "paymentBic": "string",
- "paymentAccountHolder": "string",
- "paymentBankName": "string",
- "invoiceDateFormat": "",
- "contactName": "string",
- "contactEmail": "user@example.com",
- "contactPhone": "string",
- "defaultNotes": "string",
- "invoicePrefix": "string",
- "invoiceNumberPadding": 0,
- "invoiceNumberCounter": 0,
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Create or update company profile (upsert)
Authorizations:
Request Body schema: application/jsonrequired
| sellerName required | string non-empty |
| sellerAddressLine1 | string or null |
| sellerPostalCode | string or null |
| sellerCity | string or null |
| sellerCountryCode | string or null <= 2 characters ISO 3166-1 alpha-2 |
| sellerVatId | string or null |
| logoUrl | string or null <uri> |
| registerCourt | string or null <= 200 characters |
| registerNumber | string or null <= 200 characters |
| managingDirector | string or null <= 200 characters |
| website | string or null <= 200 characters |
| taxNumber | string or null Steuernummer |
| paymentIban | string or null |
| paymentBic | string or null |
| paymentAccountHolder | string or null |
| paymentBankName | string or null |
| invoiceDateFormat | string Enum: "" "yyyyMMdd" Whether the issue date is part of the invoice number. Omitted means no date segment, which is this property's "no value". |
| contactName | string or null |
| contactEmail | string or null <email> |
| contactPhone | string or null |
| defaultNotes | string or null |
| defaultTitle | string or null <= 500 characters |
| defaultText | string or null <= 5000 characters |
Responses
Production
Request samples
- Payload
{- "sellerName": "string",
- "sellerAddressLine1": "string",
- "sellerPostalCode": "string",
- "sellerCity": "string",
- "sellerCountryCode": "st",
- "sellerVatId": "string",
- "registerCourt": "string",
- "registerNumber": "string",
- "managingDirector": "string",
- "website": "string",
- "taxNumber": "string",
- "paymentIban": "string",
- "paymentBic": "string",
- "paymentAccountHolder": "string",
- "paymentBankName": "string",
- "invoiceDateFormat": "",
- "contactName": "string",
- "contactEmail": "user@example.com",
- "contactPhone": "string",
- "defaultNotes": "string",
- "defaultTitle": "string",
- "defaultText": "string"
}Response samples
- 200
- 400
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "sellerName": "string",
- "sellerAddressLine1": "string",
- "sellerPostalCode": "string",
- "sellerCity": "string",
- "sellerCountryCode": "string",
- "sellerVatId": "string",
- "taxNumber": "string",
- "isKleinunternehmer": true,
- "paymentIban": "string",
- "paymentBic": "string",
- "paymentAccountHolder": "string",
- "paymentBankName": "string",
- "invoiceDateFormat": "",
- "contactName": "string",
- "contactEmail": "user@example.com",
- "contactPhone": "string",
- "defaultNotes": "string",
- "invoicePrefix": "string",
- "invoiceNumberPadding": 0,
- "invoiceNumberCounter": 0,
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Partially update company settings (§19 Kleinunternehmer flag)
Owner/admin-gated, audit-logged partial update. v1 carries exactly the isKleinunternehmer flag (legally significant — it governs §14 evaluation and is snapshotted onto every future invoice at creation). Distinct from the whole-profile PUT; unknown keys are rejected.
Authorizations:
Request Body schema: application/jsonrequired
| isKleinunternehmer required | boolean Whether the seller is a Kleinunternehmer (§19 UStG). |
Responses
Production
Request samples
- Payload
{- "isKleinunternehmer": true
}Response samples
- 200
- 400
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "sellerName": "string",
- "sellerAddressLine1": "string",
- "sellerPostalCode": "string",
- "sellerCity": "string",
- "sellerCountryCode": "string",
- "sellerVatId": "string",
- "taxNumber": "string",
- "isKleinunternehmer": true,
- "paymentIban": "string",
- "paymentBic": "string",
- "paymentAccountHolder": "string",
- "paymentBankName": "string",
- "invoiceDateFormat": "",
- "contactName": "string",
- "contactEmail": "user@example.com",
- "contactPhone": "string",
- "defaultNotes": "string",
- "invoicePrefix": "string",
- "invoiceNumberPadding": 0,
- "invoiceNumberCounter": 0,
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Get the current (or a specific historical) DATEV mapping config
Without ?version, returns the latest stored config — or the version-0 built-in SKR default if nothing has ever been stored (zero-config behavior). ?version=N returns that exact historical version unchanged (?version=0 always resolves to the built-in default, which is never a stored row). The response always includes version-history metadata (latestVersion, isBuiltInDefault) alongside the config fields.
Authorizations:
query Parameters
| version | integer >= 0 A specific historical version to fetch. 0 = built-in SKR default. |
Responses
Production
Response samples
- 200
- 400
{- "skr": "03",
- "sachkontenlaenge": 4,
- "beraternummer": "",
- "mandantennummer": "",
- "wjStartMonth": 1,
- "festschreibung": 0,
- "debitorStrategy": "collective",
- "collectiveDebitorKonto": 0,
- "debitorAccounts": {
- "property1": 0,
- "property2": 0
}, - "revenueAccounts": [
- {
- "taxRatePercent": "19",
- "vatCategory": "S",
- "konto": 8400,
- "buSchluessel": 0
}
], - "version": 0,
- "createdAt": "2019-08-24T14:15:22Z",
- "createdBy": "string",
- "latestVersion": 0,
- "isBuiltInDefault": true
}Create a new immutable DATEV mapping-config version
Always creates a NEW version (append-only, never an in-place update) and returns it. Validates the config shape and, where statically checkable, DATEV constraints (a BU-Schlüssel set on a known Automatikkonto → 422 INVALID_MAPPING).
Authorizations:
Request Body schema: application/jsonrequired
| skr required | string Enum: "03" "04" |
| sachkontenlaenge required | integer [ 4 .. 8 ] |
| beraternummer | string 0–7 digits |
| mandantennummer | string 0–5 digits |
| wjStartMonth required | integer [ 1 .. 12 ] |
| festschreibung required | integer Enum: 0 1 |
| debitorStrategy required | string Enum: "collective" "per_customer" |
| collectiveDebitorKonto | integer Required when debitorStrategy = collective. |
object customerId -> Debitorkonto. Required when debitorStrategy = per_customer. | |
required | Array of objects (DatevRevenueAccountMapping) non-empty |
Responses
Production
Request samples
- Payload
{- "skr": "03",
- "sachkontenlaenge": 4,
- "beraternummer": "",
- "mandantennummer": "",
- "wjStartMonth": 1,
- "festschreibung": 0,
- "debitorStrategy": "collective",
- "collectiveDebitorKonto": 0,
- "debitorAccounts": {
- "property1": 0,
- "property2": 0
}, - "revenueAccounts": [
- {
- "taxRatePercent": "19",
- "vatCategory": "S",
- "konto": 8400,
- "buSchluessel": 0
}
]
}Response samples
- 200
- 400
- 422
{- "skr": "03",
- "sachkontenlaenge": 4,
- "beraternummer": "",
- "mandantennummer": "",
- "wjStartMonth": 1,
- "festschreibung": 0,
- "debitorStrategy": "collective",
- "collectiveDebitorKonto": 0,
- "debitorAccounts": {
- "property1": 0,
- "property2": 0
}, - "revenueAccounts": [
- {
- "taxRatePercent": "19",
- "vatCategory": "S",
- "konto": 8400,
- "buSchluessel": 0
}
], - "version": 0,
- "createdAt": "2019-08-24T14:15:22Z",
- "createdBy": "string",
- "latestVersion": 0,
- "isBuiltInDefault": true
}Set the invoice-number format (prefix and minimum length)
Owner/admin only. The format is not part of the whole company-profile save: on that save an omitted property means "no value", and a numbering format cannot be absent, so it would have been reset to the shipped default by any save that did not mention it. Both properties are required here.
Authorizations:
Request Body schema: application/jsonrequired
| invoicePrefix required | string [ 1 .. 20 ] characters The text every invoice number starts with, for example "RE-". |
| invoiceNumberPadding required | integer [ 1 .. 10 ] The minimum number of digits the counter is padded to, for example 5 for "RE-00042". |
Responses
Production
Request samples
- Payload
{- "invoicePrefix": "string",
- "invoiceNumberPadding": 1
}Response samples
- 200
- 400
- 422
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "sellerName": "string",
- "sellerAddressLine1": "string",
- "sellerPostalCode": "string",
- "sellerCity": "string",
- "sellerCountryCode": "string",
- "sellerVatId": "string",
- "taxNumber": "string",
- "isKleinunternehmer": true,
- "paymentIban": "string",
- "paymentBic": "string",
- "paymentAccountHolder": "string",
- "paymentBankName": "string",
- "invoiceDateFormat": "",
- "contactName": "string",
- "contactEmail": "user@example.com",
- "contactPhone": "string",
- "defaultNotes": "string",
- "invoicePrefix": "string",
- "invoiceNumberPadding": 0,
- "invoiceNumberCounter": 0,
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Set the invoice-number counter
Owner/admin only. Deliberately separate from the format above: if the counter rode a wider body it would have to be restated on every format change, and restating a stale value re-issues numbers that already appear on issued documents.
Authorizations:
Request Body schema: application/jsonrequired
| invoiceNumberCounter required | integer >= 0 |
Responses
Production
Request samples
- Payload
{- "invoiceNumberCounter": 0
}Response samples
- 200
- 400
- 422
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "sellerName": "string",
- "sellerAddressLine1": "string",
- "sellerPostalCode": "string",
- "sellerCity": "string",
- "sellerCountryCode": "string",
- "sellerVatId": "string",
- "taxNumber": "string",
- "isKleinunternehmer": true,
- "paymentIban": "string",
- "paymentBic": "string",
- "paymentAccountHolder": "string",
- "paymentBankName": "string",
- "invoiceDateFormat": "",
- "contactName": "string",
- "contactEmail": "user@example.com",
- "contactPhone": "string",
- "defaultNotes": "string",
- "invoicePrefix": "string",
- "invoiceNumberPadding": 0,
- "invoiceNumberCounter": 0,
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Read the entity's SMTP/email settings (password never returned)
Non-secret settings for display. The SMTP password is write-only and is never read back. Session-authenticated surfaces only — API keys are refused.
Authorizations:
Responses
Production
Response samples
- 200
{- "host": "string",
- "port": 1,
- "secure": true,
- "username": "string",
- "fromName": "string",
- "fromEmail": "user@example.com",
- "replyTo": "user@example.com",
- "subjectTemplate": "string",
- "bodyTemplateHtml": "string"
}Create or replace the entity's SMTP/email settings
Whole-object replace — send the complete object every time. Every optional property here obeys one rule: omitting it stores nothing, so an omitted fromName or replyTo is CLEARED, changing the sender identity and the reply address on every message subsequently sent for this entity. Send the current values to keep them.
The SMTP password is NOT part of a replace. It is required only when creating the first settings object for this entity and mode (omit it then and the answer is 400 password_required); supplying it once settings exist is 400 password_not_on_replace, and nothing is written. Replace the stored password with PUT /settings/email/password. The password is encrypted at rest with the server's mail-credential key; 503 mail_key_missing when the server lacks it.
Authorizations:
Request Body schema: application/jsonrequired
| host required | string [ 1 .. 255 ] characters |
| port required | integer [ 1 .. 65535 ] |
| secure required | boolean |
| username required | string [ 1 .. 255 ] characters |
| password | string [ 1 .. 1024 ] characters CREATE-ONLY, and REQUIRED there. Send it when no settings exist yet for this entity and mode — that first save is the only call that can carry it, because the stored credential cannot be absent. Once settings exist this property is REFUSED (400 |
| fromName | string <= 200 characters Optional display name in the |
| fromEmail required | string <email> <= 254 characters |
| replyTo | string or null <email> Optional |
| subjectTemplate required | string [ 1 .. 998 ] characters |
| bodyTemplateHtml required | string [ 1 .. 50000 ] characters |
Responses
Production
Request samples
- Payload
{- "host": "string",
- "port": 1,
- "secure": true,
- "username": "string",
- "password": "string",
- "fromName": "string",
- "fromEmail": "user@example.com",
- "replyTo": "user@example.com",
- "subjectTemplate": "string",
- "bodyTemplateHtml": "string"
}Response samples
- 200
- 400
- 503
{- "host": "string",
- "port": 1,
- "secure": true,
- "username": "string",
- "fromName": "string",
- "fromEmail": "user@example.com",
- "replyTo": "user@example.com",
- "subjectTemplate": "string",
- "bodyTemplateHtml": "string"
}Replace the stored SMTP password, and nothing else
Replaces the stored SMTP password for this entity and mode. The single property is REQUIRED, so this body carries no omission question at all, and no other stored value can be reached from here — the sender identity, the reply address and the templates are untouched. Unknown properties are refused. The password is write-only: it is never read back by any endpoint. Session-authenticated surfaces only — API keys are refused.
Authorizations:
Request Body schema: application/jsonrequired
| password required | string [ 1 .. 1024 ] characters The new SMTP password. Write-only; it is never read back. |
Responses
Production
Request samples
- Payload
{- "password": "string"
}Response samples
- 400
- 409
- 503
{- "error": "validation_error",
- "issues": [
- {
- "path": "string",
- "message": "string"
}
]
}Send a one-off test email using the saved SMTP settings
Synchronous, bounded to 10 s. Uses the ALREADY-SAVED config (configure + PUT first). Not persisted to the outbox/log — a transient diagnostic.
Authorizations:
Request Body schema: application/jsonrequired
| to required | string <email> <= 254 characters |
Responses
Production
Request samples
- Payload
{- "to": "user@example.com"
}Response samples
- 200
- 400
- 502
- 503
- 504
{- "ok": true,
- "error": "string"
}Read the reply address for platform-provided sending
Under platform-provided sending, every field a receiving mail system authenticates belongs to the platform. The reply address is the ONLY sender field the customer controls, and it is used only after they have proven they hold it. This read publishes both facts separately: the address that has been PROVEN, and any address that is still WAITING for confirmation. Changing the address therefore never disturbs the proven one — nothing stops while a new address is being confirmed. Session-authenticated surfaces only; API keys are refused. Available only where platform-provided sending is enabled for the deployment.
Authorizations:
Responses
Production
Response samples
- 200
{- "verified": {
- "address": "buchhaltung@mueller.example",
- "verifiedAt": "2019-08-24T14:15:22Z"
}, - "pending": {
- "address": "buchhaltung@mueller.example",
- "expiresAt": "2019-08-24T14:15:22Z"
}
}Set the reply address and start confirming it
Records the address as PENDING and emails a six-digit confirmation code to it, together with a confirmation link. The address is not used for anything until it is confirmed. Calling this again replaces the pending address and issues a fresh code; a previously proven address is left untouched. Requires the admin role — deciding where a customer's replies land is configuration work.
Authorizations:
Request Body schema: application/jsonrequired
| address required | string <email> <= 254 characters The domain part is lower-cased; the part before the @ is left exactly as sent, because it is case-sensitive by definition and folding it would address a different mailbox on the servers that honour that. |
Responses
Production
Request samples
- Payload
{- "address": "buchhaltung@mueller.example"
}Response samples
- 202
- 400
{- "address": "buchhaltung@mueller.example",
- "expiresAt": "2019-08-24T14:15:22Z"
}Send a fresh confirmation code for the pending reply address
Issues a NEW code for the address already awaiting confirmation and re-sends the mail. The previous code stops working immediately, so a code read from an older mail will be refused. A short minimum interval applies between two mails for the same address. Requires the admin role.
Authorizations:
Responses
Production
Response samples
- 202
- 409
- 429
{- "address": "buchhaltung@mueller.example",
- "expiresAt": "2019-08-24T14:15:22Z"
}Confirm the pending reply address with the six-digit code
The primary way to confirm a reply address: the person who entered it types the code from the mail into the same page. A code is accepted only for the account it was issued to, only until it expires, and only a small number of times before it is discarded and a new one must be requested. The number of remaining attempts is deliberately not disclosed. Requires the admin role.
Authorizations:
Request Body schema: application/jsonrequired
| code required | string^[0-9]{6}$ The six-digit code from the confirmation mail. Spaces are ignored, so a code read out in pairs can be typed as it was heard; nothing else is corrected. |
Responses
Production
Request samples
- Payload
{- "code": "204815"
}Response samples
- 200
- 409
- 410
- 422
- 429
{- "address": "buchhaltung@mueller.example",
- "verifiedAt": "2019-08-24T14:15:22Z"
}Confirm a reply address from the emailed link (no sign-in required)
The fallback path, for the common case where the mailbox being confirmed is a shared accounting inbox opened by somebody who is not signed in and may not be a user of the account at all. It is reachable without any credential, and the single-use token from the mail is the only thing that identifies the request. It is a POST on purpose and there is no GET equivalent: mail-security systems fetch every link in an incoming message, and a single-use link would be spent before the recipient ever saw it. For the same reason the page carrying this form performs no action and issues no request until a person deliberately submits it. The endpoint is rate limited per client address.
Request Body schema: application/jsonrequired
| token required | string [ 16 .. 512 ] characters The single-use token from the confirmation link. |
Responses
Production
Request samples
- Payload
{- "token": "stringstringstri"
}Response samples
- 200
- 410
{- "address": "buchhaltung@mueller.example",
- "verifiedAt": "2019-08-24T14:15:22Z"
}List the authenticated caller's active tenant memberships
Drives the dashboard app's tenant-switcher UI, which needs to enumerate which tenants the logged-in user belongs to BEFORE any of them is selected. Requires only a verified OIDC token (requireAuth()) — no X-Tenant-Id header, since choosing one is exactly the thing this response lets the caller do next. JIT- provisions a users row for a never-before-seen sub, same provisioning path requireTenantContext() uses.
Only active memberships are returned — a pending invitation is not a membership yet. A caller with zero active memberships gets 200 { memberships: [] }, never a 403: the whole point of this route is to let the frontend render a "no access" state from the body, not from a status code.
Authorizations:
Responses
Production
Response samples
- 200
{- "memberships": [
- {
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "tenantName": "string",
- "tenantSlug": "string",
- "role": "owner",
- "status": "pending"
}
]
}Create a tenant (self-serve, member-less principal becomes owner)
A just-authenticated but MEMBER-LESS principal creates a tenant and becomes its owner. Mounted under the global requireAuth() ONLY (not requireTenantContext()) — the POST /invitations/accept shape; the caller has no tenant context yet. Behind the self-serve registration capability (off by default): when off the route is absent (404). Body is BUSINESS DATA ONLY — never a credential; unknown fields are stripped. Idempotency is by the natural key (the claiming principal): a retry returns the principal's EXISTING tenant (200), never a second. A per-IP velocity limiter (Richtwert ~5/IP/hr) returns 429 as defense-in-depth; the one-self-serve- tenant-per-principal DB guard is the primary control.
Authorizations:
Request Body schema: application/jsonrequired
| name required | string [ 1 .. 200 ] characters Organisation / company name. |
| vatId | string Optional Rechnungsabsender USt-IdNr (form scope deferred). |
| addressLine1 | string |
| postalCode | string |
| city | string |
| countryCode | string = 2 characters ISO 3166-1 alpha-2. |
Array of objects non-empty The legal documents this registrant accepts, each named with the version that was shown to them. Read the current versions and their acceptance sentences from |
Responses
Production
Request samples
- Payload
{- "name": "Muster GmbH",
- "legalAcceptances": [
- {
- "document": "avv",
- "version": "v1"
}, - {
- "document": "agb",
- "version": "v1"
}
]
}Response samples
- 200
- 201
- 400
- 409
- 429
{- "tenant": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "slug": "string",
- "name": "string",
- "planTier": "launch"
}, - "membership": {
- "role": "owner"
}, - "entity": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string"
}
}The tenant's plan-tier readout
Returns { planTier, entityLimit, entityCount } — the plan tier, its EFFECTIVE active-entity cap (entityLimit, null = unbounded/Sovereign), and the AUTHORITATIVE active-entity count (entityCount, the same number the create-time cap check counts, NOT a page length). Read open to ANY active member (requireTenantContext, no role required) — it mirrors the 409 entity_limit_reached body's { limit, planTier } plus a count, so the pre-cap "X von Y" line and the post-cap prompt can never disagree. Mode-independent (an entity exists across live and test; the plan cap is a tenant-plan fact). Scoped to the resolved context tenant; the path :tenantId must equal it (else 403 tenant_mismatch).
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant being managed. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
Responses
Production
Response samples
- 200
{- "planTier": "launch",
- "entityLimit": 0,
- "entityCount": 0
}List the tenant's entities (cursor-paginated)
The active entities of the caller's tenant, oldest first, cursor- paginated ({ data, nextCursor }, keyset over (created_at, id)). Requires entities:read: readable by ANY active OIDC member (all roles, incl. readonly, hold entities:read via scopesForRole() — the list/picker stays visible-but-restricted) OR an API key carrying entities:read. Soft-deleted (deactivated) entities are omitted. Scoped to the resolved context tenant; the path :tenantId must equal it (else 403 tenant_mismatch).
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant being managed. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
Responses
Production
Response samples
- 200
- 400
{- "data": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "name": "string",
- "vatId": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "status": "active",
- "deletedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "liveOperationActivatedAt": "2019-08-24T14:15:22Z"
}
], - "nextCursor": "string"
}Create an entity (admin/owner or API key with entities:write)
Provisions a new entity under the tenant. Requires entities:write AND — for an OIDC/dashboard caller only — the admin role; an API key carrying entities:write may provision entities programmatically (it has no role, so the admin gate does not apply to it). Enforces the plan-tier entity cap (tenants.max_entities, NULL = tier default: Launch=1, Platform=25, Sovereign=unbounded) for BOTH credential paths — exceeding it is 409 entity_limit_reached with a limit/planTier/upgradeUrl, so a scope is access, never a cap bypass. The new entity is usable immediately as a managed resource; note that repository-mediated DOCUMENT issuance for a non-genesis entity is gated on the entity-scoped write path. Honors the optional Idempotency-Key header so a retried provisioning is a replay, not a second entity that would burn a plan-tier slot.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant being managed. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
header Parameters
| Idempotency-Key | string <= 255 characters Optional client-supplied key that makes this mutating request safe to retry. The FIRST request runs and its outcome is recorded keyed by |
Request Body schema: application/jsonrequired
| name required | string [ 1 .. 200 ] characters |
| vatId | string or null |
| addressLine1 | string or null |
| postalCode | string or null |
| city | string or null |
| countryCode | string = 2 characters ISO 3166-1 alpha-2. |
Responses
Production
Request samples
- Payload
{- "name": "string",
- "vatId": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "st"
}Response samples
- 201
- 409
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "name": "string",
- "vatId": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "status": "active",
- "deletedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "liveOperationActivatedAt": "2019-08-24T14:15:22Z"
}Get one entity (the settings read)
One entity of the caller's tenant. Readable by any active member. 404 if not visible.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant being managed. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
| id required | string <uuid> Entity id. |
Responses
Production
Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "name": "string",
- "vatId": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "status": "active",
- "deletedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "liveOperationActivatedAt": "2019-08-24T14:15:22Z"
}Update entity settings (admin/owner or API key with entities:write)
Patch mutable settings (name/VAT/address). Requires entities:write AND — for OIDC callers — the admin role. Only the fields present are changed; at least one is required. The VAT ID is IMMUTABLE once the entity has issued a document — attempting to change it then is 422 immutable_after_invoices (a no-op same-value PATCH passes).
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant being managed. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
| id required | string <uuid> Entity id. |
Request Body schema: application/jsonrequired
non-empty | name | string [ 1 .. 200 ] characters |
| vatId | string or null Rejected with 422 if changed after issuance. |
| addressLine1 | string or null |
| postalCode | string or null |
| city | string or null |
| countryCode | string = 2 characters |
Responses
Production
Request samples
- Payload
{- "name": "string",
- "vatId": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "st"
}Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "name": "string",
- "vatId": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "status": "active",
- "deletedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "liveOperationActivatedAt": "2019-08-24T14:15:22Z"
}Soft-delete (deactivate) an entity (admin/owner or API key with entities:write)
Requires entities:write AND — for OIDC callers — the admin role. Sets status='deactivated'/deleted_at — blocks new issuance, RETAINS every existing document/archive (true purge is a separate surface, not here). Idempotent. Rejects deactivating the tenant's LAST active entity (409 last_active_entity) so a resolved context always has an entity.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant being managed. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
| id required | string <uuid> Entity id. |
Responses
Production
Move this company into live operation — deliberate, audited, once
The one act that takes a company which has only ever rehearsed and lets it operate for real. It stamps liveOperationActivatedAt exactly once and answers 200 with the entity; repeating the call replays the same timestamp and writes no second audit row (the act is idempotent — the repository's IS NULL latch makes it exactly-once even without an Idempotency-Key).
Restricted to an interactive admin session: an API credential is refused outright (403 credential_not_accepted), because a key carries its own immutable environment and the act must be performed by a person, once, audited. Never a side effect of a settings save — the general PATCH cannot reach it.
There is NO de-activation verb: putting a live company back into the rehearsal environment would hide its whole history behind row-level security. When the capability is switched off this path does not exist at all (404).
Entering live operation requires a plan that includes live issuance: an account on the free sandbox plan is refused with 402 entitlement_required. The refusal applies to the TRANSITION only — a company that already carries liveOperationActivatedAt keeps replaying 200 whatever the plan is now, so a later plan change never turns a settled activation into an error. The plan refusal and the credential refusal are independent: an API credential on a paid plan is still a 403, and an admin session on the free plan is still a 402.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant being managed. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
| id required | string <uuid> Entity id. |
header Parameters
| Idempotency-Key | string <= 255 characters Optional client-supplied key that makes this mutating request safe to retry. The FIRST request runs and its outcome is recorded keyed by |
Responses
Production
Response samples
- 200
- 402
- 409
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "name": "string",
- "vatId": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "status": "active",
- "deletedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "liveOperationActivatedAt": "2019-08-24T14:15:22Z"
}Whether this company operates live
The single platform predicate behind a fresh session's default environment. Answers for the company resolved from the caller's context (X-Entity-Id, or the tenant's oldest-active entity when absent) and ECHOES which company it answered for. Open to every active member including readonly; carries no plan, no tier and no payment state. The answer is INDEPENDENT of the environment the caller states — see basis.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant being managed. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
Responses
Production
Response samples
- 200
- 403
{- "operatesLive": true,
- "basis": "activated",
- "activatedAt": "2019-08-24T14:15:22Z",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5"
}List the tenant's members (the Team table)
Every active (and pending) membership of the tenant, each joined with the member's email — the "Team & Zugriff" roster. Readable by ANY active member (visible-but-restricted for readonly/member). Revoked (removed) members are omitted.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant being managed. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
Responses
Production
Response samples
- 200
{- "data": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "userId": "2c4a230c-5085-4924-a3e1-25fb4fc5965b",
- "email": "string",
- "role": "owner",
- "status": "active",
- "createdAt": "2019-08-24T14:15:22Z",
- "lastActiveAt": "2019-08-24T14:15:22Z",
- "keysOwned": {
- "live": 0,
- "test": 0
}
}
]
}Change a member's role (admin/owner, inline role change)
Sets a member's role. Admin/owner only. Guardrails, surfaced as machine-readable errors the UI maps: you cannot change your OWN role (409 cannot_change_own_role); you cannot demote the tenant's LAST owner (409 last_owner). Role is re-read per request, so the change takes effect on the target's very next call.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant being managed. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
| userId required | string <uuid> The member's |
Request Body schema: application/jsonrequired
| role required | string (MembershipRole) Enum: "owner" "admin" "member" "readonly" Total order ( |
Responses
Production
Request samples
- Payload
{- "role": "owner"
}Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "userId": "2c4a230c-5085-4924-a3e1-25fb4fc5965b",
- "email": "string",
- "role": "owner",
- "status": "active",
- "createdAt": "2019-08-24T14:15:22Z",
- "lastActiveAt": "2019-08-24T14:15:22Z",
- "keysOwned": {
- "live": 0,
- "test": 0
}
}Remove a member (admin/owner)
Soft-revokes the membership (status='revoked') — the member loses access on their very next request. Admin/owner only. Rejects removing the tenant's LAST owner (409 last_owner). Self-removal is allowed (subject to the last-owner rule).
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant being managed. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
| userId required | string <uuid> The member's |
Responses
Production
Leave the organisation ("Organisation verlassen")
The caller leaves the tenant themselves — the self-service half of member management. Open to ANY active member (NO admin role required): the target is the CALLER'S OWN userId, derived from the verified OIDC token (never a path/body param), so it is unspoofable — a caller can only ever leave for themselves. A dashboard OIDC session only; API-key credentials are refused (oidc-only). Soft-revokes the caller's membership (status='revoked') — they lose access on their very next request. The SOLE owner cannot leave (409 last_owner): the org must always keep ≥1 active owner, so the UI must explain the guard (transfer ownership first, or route to tenant closure) rather than surfacing a blank 409. On losing the caller's LAST active membership anywhere, an identity-provider deactivate is enqueued (best-effort, and only where directory provisioning is enabled for the deployment). Audit membership.revoked, actor = the leaver.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant being managed. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
Responses
Production
The access-review stamp (admin)
The persistent "Zugriff zuletzt geprüft am {Datum} von {Person}" stamp for the Team & Zugriff header — the MOST RECENT access_review.marked_reviewed for the tenant. Admin-open (does NOT ride the owner-only audit-log read). { lastReviewedAt: null, lastReviewedBy: null } when the tenant has never been reviewed.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant being managed. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
Responses
Production
Response samples
- 200
{- "lastReviewedAt": "2019-08-24T14:15:22Z",
- "lastReviewedBy": "string"
}Mark tenant access as reviewed (admin, non-idempotent)
Records that an admin reviewed the tenant's access — writes ONE audit-log entry (access_review.marked_reviewed, actor = the reviewing admin). Deliberately NON-idempotent: a review is a repeatable dated act, so each call is a new artifact (two presses → two rows). Returns the fresh stamp. Admin/owner only; path :tenantId cross-checked.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant being managed. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
Responses
Production
Response samples
- 200
{- "lastReviewedAt": "2019-08-24T14:15:22Z",
- "lastReviewedBy": "string"
}List the tenant's pending invitations ("ausstehend")
Every still-pending invitation of the tenant (consumed_at IS NULL, and not withdrawn), newest first. Readable by ANY active member. The show-once token is NEVER returned (only its SHA-256 hash is stored, and not even the hash reaches the wire).
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant being managed. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
Responses
Production
Response samples
- 200
{- "data": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "email": "user@example.com",
- "role": "owner",
- "expiresAt": "2019-08-24T14:15:22Z",
- "consumedAt": "2019-08-24T14:15:22Z",
- "revokedAt": "2019-08-24T14:15:22Z",
- "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e",
- "createdAt": "2019-08-24T14:15:22Z"
}
]
}Create or re-invite an invitation (admin/owner)
Invites email at role. Admin/owner only (requireRole('admin')). Re-invite-as-UPDATE: if the (tenant, email) already has a pending invitation, its token/expiry/role are REFRESHED in place (the same row, never a second one) and the previously emailed link dies immediately — so this is idempotent and always returns 201. DB-first and NO IdP call at invite time — the row commits, then the invitation email is enqueued unconditionally (delivery is no longer coupled to identity-provider availability; the IdP account is minted by the provider's enrollment flow when the invitee authenticates). A genuinely concurrent second pending INSERT for the same email is 409 pending_invitation_exists (the partial-unique index race — the caller should refresh, not duplicate). No membership is created here — that only happens on ACCEPT. Honors the optional Idempotency-Key header so a retried create is a replay, not a duplicate side effect.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant being managed. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
header Parameters
| Idempotency-Key | string <= 255 characters Optional client-supplied key that makes this mutating request safe to retry. The FIRST request runs and its outcome is recorded keyed by |
Request Body schema: application/jsonrequired
| email required | string <email> <= 320 characters Trimmed + lower-cased server-side. |
| role required | string (MembershipRole) Enum: "owner" "admin" "member" "readonly" Total order ( |
Responses
Production
Request samples
- Payload
{- "email": "user@example.com",
- "role": "owner"
}Response samples
- 201
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "email": "user@example.com",
- "role": "owner",
- "expiresAt": "2019-08-24T14:15:22Z",
- "consumedAt": "2019-08-24T14:15:22Z",
- "revokedAt": "2019-08-24T14:15:22Z",
- "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e",
- "createdAt": "2019-08-24T14:15:22Z"
}Withdraw a pending invitation (admin/owner, "Zurückziehen")
Withdraws a pending invitation by setting a dedicated revoked_at (distinct from consumed_at/accepted, for audit clarity). Admin/owner only. The live invitation link dies immediately — a subsequent accept of that token returns 410 invitation_revoked. Idempotent: re-withdrawing an already-withdrawn invite is a no-op success (204). Withdrawing an already-ACCEPTED invite is 409 invitation_already_accepted (you cannot un-accept a membership this way — revoking an active membership is a separate DELETE .../memberships/:userId operation). A pending invite's (tenant, email) becomes free to invite again after withdrawal (the partial-unique index excludes revoked rows). Writes an invitation.withdrawn audit row on the actual transition.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant being managed. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
| id required | string <uuid> Invitation id. |
Responses
Production
Resend a pending invitation (admin/owner)
Refreshes a specific pending invite's token/expiry so a new link can be emailed; the old link dies immediately. Admin/owner only. 404 if no such pending invite exists in the tenant; 409 invitation_already_accepted if it has already been accepted (you cannot resend a consumed invite).
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant being managed. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
| id required | string <uuid> Invitation id. |
Responses
Production
Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "email": "user@example.com",
- "role": "owner",
- "expiresAt": "2019-08-24T14:15:22Z",
- "consumedAt": "2019-08-24T14:15:22Z",
- "revokedAt": "2019-08-24T14:15:22Z",
- "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e",
- "createdAt": "2019-08-24T14:15:22Z"
}Accept an invitation by token (any signed-in user)
The token-based acceptance endpoint the web /invite/[token] page calls. Gated by requireAuth() ONLY — NOT requireTenantContext(): the accepting user is a signed-in OIDC session that is NOT yet a member of the target tenant, so a membership-resolving gate would 403 them before they could join. The tenant is resolved from the TOKEN. The caller's identity (sub/email) comes from the verified session, never the body. Idempotent — a re-click after acceptance returns already_member, never a duplicate membership.
Machine-readable per-state outcomes (the page keys its copy off these — an HTTP status alone can't distinguish success from already-member, or expired from revoked):
200 { outcome: "accepted", tenant, role }— newly activated (State 1).200 { outcome: "already_member", tenant }— idempotent re-click (State 2).403 { error: "invitation_email_mismatch" }— the signed-in email differs from the invited address (State 3). The invited address is NEVER echoed back (GDPR/data-minimization).404 { error: "invitation_not_found" }— no row for this token: a garbage token OR a superseded re-invite token (State 4; the old token dies the instant a re-invite/resend refreshes the row).410 { error: "invitation_expired", expiresAt }— a live token past its expiry (State 5).410 { error: "invitation_revoked" }— the invite was withdrawn (State 7).
Authorizations:
Request Body schema: application/jsonrequired
| token required | string [ 1 .. 512 ] characters The show-once invitation token from the emailed link. |
Responses
Production
Request samples
- Payload
{- "token": "string"
}Response samples
- 200
{- "outcome": "accepted",
- "tenant": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string"
}, - "role": "owner",
- "inviterName": "string"
}Preview an invitation by token (UNAUTHENTICATED)
The public, signed-out preview the web /invite/[token] page calls BEFORE the invitee authenticates. An EXPLICIT carve-out of the default-deny gate — the ONLY unauthenticated route under /invitations (the accept POST /invitations/accept stays gated). Privacy / disclosure boundary: a live/pending token discloses the tenantName, the invitee's own role, and best-effort tenant branding (logoUrl/primaryColor) — all gated behind possession of the 32-byte token. EVERY other case — expired, revoked, consumed, or nonexistent — returns the BYTE-IDENTICAL { valid: false } at HTTP 200, so the endpoint can never distinguish invitation states. The invited email, the INVITER's name (post-auth-only), and the tenant id are NEVER returned. No side effects — the invitation row is never consumed/extended/revoked/logged. Rate-limited per client IP (in-process; the 32-byte token makes brute force infeasible regardless). authHint is constant 'password' in v1 (the SSO routing hook).
path Parameters
| token required | string [ 1 .. 512 ] characters The show-once invitation token from the emailed link (32 random bytes, base64url). |
Responses
Production
Response samples
- 200
{- "valid": true,
- "tenantName": "string",
- "authHint": "password",
- "role": "owner",
- "logoUrl": "string",
- "primaryColor": "string"
}The counsel-owned retention disclosure shown before account closure
The honesty core of the account-closure flow. Returns the structured, api-owned retention disclosure the UI renders VERBATIM (no client-side legal mapping, no hard-coded legal claim in the frontend). Ships a CONSERVATIVE placeholder — retentionYears: null, legalBasis: null, classification: "undetermined" — until legal counsel rules the exact values (a one-line config change, no re-model). The endpoint enforces the single hard invariant IN CODE: it never emits a hard-coded retention number while classification !== "ruled", and never emits a "fully deleted" claim (neverFullErasure is always true), so the UI cannot regress the honesty invariant.
Available only where self-service account closure is enabled (off by default); 404 when not.
Authorizations:
Responses
Production
Response samples
- 200
{- "retentionYears": 0,
- "legalBasis": "string",
- "minimizationNote": "string",
- "classification": "undetermined",
- "neverFullErasure": true
}Preconditions that must be resolved before the caller can close their account
The authoritative pre-close read the UI renders as a resolvable checklist (never a bare 409, never a client-side last-ownership computation — the api is authoritative). Self-only: the subject is the verified token's user (unspoofable — no path/body/query userId). Returns last_owner_tenant for every tenant the caller is the SOLE active owner of, and active_subscription for every owned tenant with a live Fortlauf billing mandate. The subscription check is FEATURE-DETECTED: when billing is not enabled (the default) it collapses to empty. An empty blockers array means the caller may proceed to close.
Available only where self-service account closure is enabled (off by default); 404 when not.
Authorizations:
Responses
Production
Response samples
- 200
{- "blockers": [
- {
- "type": "last_owner_tenant",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "tenantName": "string",
- "periodEnd": "2019-08-24T14:15:22Z"
}
]
}Close the caller's account and erase their identity (Art. 17)
The self-service Art. 17 identity-erasure path. Self-only / unspoofable: the subject is the verified token user (no path/body/query userId). One auditable workflow: re-check blockers (→ 409 with the machine-readable blocker body, no side effect) → revoke the identity-provider login (deactivate, never delete) → revoke every membership → SCRUB identity PII (email→NULL, sub→deleted:<id> tombstone, status='closed') and write the erasure-evidence record in ONE atomic transaction. users.id is KEPT — erasure is tombstone+scrub, never a hard DELETE (the retained audit trail references it). On a GoBD-bound platform the issued invoices + WORM archive are legally retained and are NOT erased — the disclosure (GET /me/account/close-disclosure) states this honestly.
Idempotent: an already-closed (or absent) subject is a no-op already_closed. Available only where self-service account closure is enabled (404 when not).
Authorizations:
Responses
Production
Response samples
- 200
- 409
{- "status": "closed",
- "userId": "2c4a230c-5085-4924-a3e1-25fb4fc5965b"
}The legal documents currently published, and the sentence each is accepted by
What a customer is being asked to agree to right now. Answers for the PLATFORM, not for the caller — it is deliberately not "what does this account still owe", which is a tenant-scoped question and a different one. A signed-in caller who belongs to no account may read it: they are exactly who is about to create one.
An empty list means nothing is published and there is no requirement, so a client renders no acceptance control at all. That is a 200, never a 404: a 404 could not be told apart from a deployment that does not have this route yet.
acceptanceSentence is the exact wording the customer agrees to and is echoed verbatim from the published version. A client must render it rather than substituting its own copy — the version identifier is what pins the words, and paraphrasing breaks that. documentUrl is omitted when no address is configured, in which case there is no link and therefore no control.
Authorizations:
Responses
Production
Response samples
- 200
{- "documents": [
- {
- "document": "string",
- "version": "string",
- "acceptanceSentence": "string",
- "capacitySentence": "string",
- "documentUrl": "string"
}
]
}Tenant-scoped audit log (cursor-paginated, owner-only)
Append-only authorization-event trail for one tenant. Owner role only (requireTenantContext + requireRole('owner') — stricter than the admin gate on api-keys/webhooks; the audit log answers "who did what and when" for security/DSGVO). Cursor-paginated ({ data, nextCursor }, keyset over (timestamp, id) DESC — newest first), the same envelope as the webhook delivery log. Metadata only: the hashed IP is NEVER returned (the raw IP is never stored — hashed at the repository boundary, DSGVO), and tenantId is omitted (implied by the path). Entries are scoped to the resolved tenant; the path :tenantId must equal the caller's active tenant or the request is 403 tenant_mismatch. Consumed by the Übersicht activity feed and the Team surface.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant whose audit log to read. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
query Parameters
| cursor | string Opaque keyset cursor from a prior page's |
| limit | integer [ 1 .. 100 ] Default: 25 Page size for cursor-paginated reads. |
| eventType | string Filter by action, e.g. |
| actorId | string <uuid> Filter by actor ( |
| resourceType | string Filter by target kind, e.g. |
| from | string <date-time> Inclusive |
| to | string <date-time> Inclusive |
Responses
Production
Response samples
- 200
- 400
{- "data": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "eventType": "user.login",
- "actorId": "string",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "resourceType": "string",
- "resourceId": "string",
- "timestamp": "2019-08-24T14:15:22Z",
- "requestId": "string"
}
], - "nextCursor": "string"
}Usage snapshot for the period (finalized-document rollup, admin/owner)
The billable-usage snapshot the Übersicht card shows: the count of finalized documents (invoice_finalized / credit_note_finalized / correction_finalized) for the period, scoped to the caller's resolved tenant/entity. Admin/owner only (requireTenantContext + requireRole('admin')); a key-bearing caller carries no role and is refused (403 insufficient_role). Metadata only — COUNTS, never document contents. Live only — sandbox (mode='test') documents are excluded at the query layer (the BillingRollupQuery boundary). A tenant sees only its own counts; the path :tenantId must equal the caller's active tenant or the request is 403 tenant_mismatch. Defaults to the current calendar month (UTC) when no from/to is given.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant whose usage to read. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
query Parameters
| from | string <date-time> Inclusive lower bound of the reporting window (RFC3339, half-open |
| to | string <date-time> Exclusive upper bound (RFC3339). Must be supplied together with |
Responses
Production
Response samples
- 200
- 400
{- "period": {
- "from": "2019-08-24T14:15:22Z",
- "to": "2019-08-24T14:15:22Z"
}, - "totalBillableUnits": 0,
- "byEventType": {
- "invoice_finalized": 0,
- "credit_note_finalized": 0,
- "correction_finalized": 0
}
}The tenant's billing status (tier + effective price + masked account + usage)
The self-serve billing-status read the "Abrechnung" surface consumes: the tenant's plan tier, the resolved plan (label + entitlements + its EFFECTIVE price), the MASKED billing account, and the current-period usage. Read open to ANY active member (requireTenantContext, no role required) — it mirrors GET .../plan; the path :tenantId must equal the caller's active tenant (else 403 tenant_mismatch). Reads-only — no Mollie/provider call, no issued-invoice mutation. Key invariants: plan.price is resolved through the override-aware resolveEffectivePricing seam (NOT PLAN_CATALOG_V1 directly), so a future gated per-tenant price override flows here with no consumer change; every €-figure is server-sourced and carries provisional: true; usage.billableUnits is BYTE-EQUAL to GET .../usage for the same period (the §7 bill == usage invariant); account is null when billing is not enabled or no account row exists yet, and NEVER exposes providerCustomerId/mandateRef.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant whose billing status to read. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
Responses
Production
Response samples
- 200
{- "tier": "launch",
- "plan": {
- "label": "string",
- "entitlements": {
- "includedDocumentsPerPeriod": 0
}, - "price": {
- "provisional": true,
- "currency": "EUR",
- "baseMonthlyMinor": 0,
- "overagePerDocumentMinor": 0,
- "capMonthlyMinor": 0
}
}, - "account": {
- "provider": "mock",
- "mandateStatus": "none",
- "defaultMethod": "sepa"
}, - "usage": {
- "period": {
- "start": "2019-08-24",
- "end": "2019-08-24"
}, - "billableUnits": 0,
- "includedDocumentsPerPeriod": 0,
- "overageUnits": 0
}, - "entitlement": {
- "includedDocumentsPerPeriod": 0,
- "billableUnits": 0,
- "overageUnits": 0,
- "status": "within_allowance",
- "liveIssuance": true,
- "band": "none",
- "consequence": "included",
- "periodResetsOn": "2019-08-24"
}, - "cancellation": {
- "available": true,
- "prospectiveEffectiveAt": "2019-08-24T14:15:22Z",
- "requestedAt": "2019-08-24T14:15:22Z",
- "effectiveAt": "2019-08-24T14:15:22Z"
}, - "paymentMethodChange": {
- "state": "pending",
- "startedAt": "2019-08-24T14:15:22Z"
}
}Start a subscription — obtain permission to charge this account
Begins the collection of a payment mandate: the standing permission that lets this account be charged for its subscription. On a rail that hosts the payment step this mints a hosted checkout and returns its address; the caller sends the person there and the mandate comes into existence only once that first payment settles, which is reported asynchronously. On a rail that captures the instrument inline there is no address and the mandate is active on return.
The response is always 200 with the destination in the body, and NEVER a redirect: the caller performs the hand-off itself, so a server-side call from a front end cannot follow the redirect and strand the person's browser. The destination is produced by the payment provider and by nothing in the request — no request property, query parameter or header can influence where a person is sent.
Restricted to an interactive owner session. An API credential may not start a subscription: the credential belongs to the caller's software, the subscription to their company. Idempotency-Key is REQUIRED, because there is no natural once-only guard on starting a subscription and a retried request must never mint a second payment page.
Only an already-ACTIVE mandate is refused (409). An account that has never had one, is still waiting on one, had one refused, or had one withdrawn may all start again. When the billing feature is switched off this path does not exist at all (404), which is how a client detects the capability without holding a switch of its own.
Billing details must be on file before a subscription can start, because German VAT law requires the recipient's full name and address on every invoice we issue. Send them as billingParty, or omit that property if they have already been supplied. Three separate refusals distinguish the cases — none supplied and none held, supplied but incomplete, and a country we cannot yet bill — so a caller can tell a person exactly what to do next. Every one of them refuses before the payment provider is contacted: nothing is stored and no payment page is minted.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant whose billing status to read. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
header Parameters
| Idempotency-Key | string <= 255 characters Optional client-supplied key that makes this mutating request safe to retry. The FIRST request runs and its outcome is recorded keyed by |
Request Body schema: application/jsonoptional
| method | string Enum: "card" "sepa" The instrument family to collect. Omitted means the platform default. A closed set on purpose: a provider-native method name here would put one provider's vocabulary on this contract. |
object (BillingParty) Who receives the invoices we issue for this subscription. German VAT law requires the recipient's full name and address on an invoice, so this must be on file before a subscription can start. It may be omitted once it has already been supplied; sending it again replaces what is held, and the replacement applies to the next invoice rather than to invoices already issued. It is never taken from the company profile used for the invoices this account itself issues. That profile describes the account as a SELLER, and reusing it would mean editing a letterhead silently changed who we bill and where. |
Responses
Production
Request samples
- Payload
{- "method": "card",
- "billingParty": {
- "companyName": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "billingEmail": "user@example.com"
}
}Response samples
- 200
{- "checkoutUrl": "string",
- "mandateStatus": "none"
}Replace the payment method this account already holds
Starts a SECOND payment-method capture while the account keeps the mandate it currently holds. The held mandate stays chargeable for the whole of the hand-off, and is withdrawn only after the replacement has been confirmed by this platform's own authenticated read — so abandoning the hosted page leaves the account exactly as it was, still able to be charged.
The response is always 200 with the destination in the body and NEVER a redirect; the caller performs the hand-off itself. The destination comes from the payment provider and from nothing in the request: no request property, query parameter or header can influence where a person is sent.
Restricted to an interactive owner session; an API credential may not change a company's payment method. Idempotency-Key is REQUIRED, and it identifies the intent rather than the attempt: repeating a request with the same key returns the same hand-off destination, while pressing the control again with a new key SUPERSEDES the earlier attempt — the earlier one can no longer complete, and a payment method it registers late is withdrawn automatically.
This operation defines no request properties at all. In particular there is no choice of instrument family: the replacement reuses the family the account already uses.
Only an account that HOLDS an active payment method may replace one (409 otherwise) — the mirror of the refusal on starting a subscription, which refuses an account that already holds one. When the capability is switched off this path does not exist at all (404), which is how a client detects it without holding a switch of its own.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant whose billing status to read. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
header Parameters
| Idempotency-Key | string <= 255 characters Optional client-supplied key that makes this mutating request safe to retry. The FIRST request runs and its outcome is recorded keyed by |
Responses
Production
Response samples
- 200
{- "replacementId": "58669121-2b56-4cbf-a20c-68f278d40aa3"
}The billing status read that still answers a SUSPENDED account
Byte-identical in shape to GET /tenants/{tenantId}/billing, on a prefix that ADMITS a suspended tenant — the one door a suspended customer may walk through to restore a means of payment. Every other route keeps refusing a suspended tenant (403 tenant_suspended), which is how a client feature-detects the suspension itself.
Restricted to an interactive (OIDC) session: an API credential is refused outright, because this door exists so a PERSON can restore a payment method. Open to any active member of the tenant. Paying never lifts a suspension — reactivation stays a human act with a recorded reason — so a renderer of this surface must not promise that a successful payment restores access. When the capability is switched off this prefix does not exist at all (404).
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant whose billing status to read. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
Responses
Production
Response samples
- 200
{- "tier": "launch",
- "plan": {
- "label": "string",
- "entitlements": {
- "includedDocumentsPerPeriod": 0
}, - "price": {
- "provisional": true,
- "currency": "EUR",
- "baseMonthlyMinor": 0,
- "overagePerDocumentMinor": 0,
- "capMonthlyMinor": 0
}
}, - "account": {
- "provider": "mock",
- "mandateStatus": "none",
- "defaultMethod": "sepa"
}, - "usage": {
- "period": {
- "start": "2019-08-24",
- "end": "2019-08-24"
}, - "billableUnits": 0,
- "includedDocumentsPerPeriod": 0,
- "overageUnits": 0
}, - "entitlement": {
- "includedDocumentsPerPeriod": 0,
- "billableUnits": 0,
- "overageUnits": 0,
- "status": "within_allowance",
- "liveIssuance": true,
- "band": "none",
- "consequence": "included",
- "periodResetsOn": "2019-08-24"
}, - "cancellation": {
- "available": true,
- "prospectiveEffectiveAt": "2019-08-24T14:15:22Z",
- "requestedAt": "2019-08-24T14:15:22Z",
- "effectiveAt": "2019-08-24T14:15:22Z"
}, - "paymentMethodChange": {
- "state": "pending",
- "startedAt": "2019-08-24T14:15:22Z"
}
}Start a subscription from a SUSPENDED account whose mandate is revoked
The recovery twin of POST /tenants/{tenantId}/billing/subscribe, on the prefix that admits a suspended tenant: for the suspended account whose payment mandate is revoked, this is the only way to establish a new one. Contract, refusals and response are the ordinary subscribe operation's — see that operation for the hand-off semantics, the billingParty requirements and the error vocabulary. Restricted to an interactive owner session; Idempotency-Key is REQUIRED. When the capability is switched off this path does not exist at all (404).
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant whose billing status to read. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
header Parameters
| Idempotency-Key | string <= 255 characters Optional client-supplied key that makes this mutating request safe to retry. The FIRST request runs and its outcome is recorded keyed by |
Request Body schema: application/jsonoptional
| method | string Enum: "card" "sepa" The instrument family to collect. Omitted means the platform default. A closed set on purpose: a provider-native method name here would put one provider's vocabulary on this contract. |
object (BillingParty) Who receives the invoices we issue for this subscription. German VAT law requires the recipient's full name and address on an invoice, so this must be on file before a subscription can start. It may be omitted once it has already been supplied; sending it again replaces what is held, and the replacement applies to the next invoice rather than to invoices already issued. It is never taken from the company profile used for the invoices this account itself issues. That profile describes the account as a SELLER, and reusing it would mean editing a letterhead silently changed who we bill and where. |
Responses
Production
Request samples
- Payload
{- "method": "card",
- "billingParty": {
- "companyName": "string",
- "addressLine1": "string",
- "postalCode": "string",
- "city": "string",
- "countryCode": "string",
- "vatId": "string",
- "billingEmail": "user@example.com"
}
}Response samples
- 200
{- "checkoutUrl": "string",
- "mandateStatus": "none"
}Replace the payment method from a SUSPENDED account whose mandate is active
The recovery twin of POST /tenants/{tenantId}/billing/payment-method/replace, on the prefix that admits a suspended tenant: for the suspended account that still HOLDS an active mandate (a reissued card, say), this replaces it while the held mandate stays chargeable for the whole hand-off. Contract, refusals and response are the ordinary replace operation's — see that operation. Both money verbs exist on this prefix deliberately: a suspended tenant whose mandate is revoked needs subscribe, one whose mandate is active needs replace, and admitting only one of them rebuilds the trap for half the population. Restricted to an interactive owner session; Idempotency-Key is REQUIRED. When the capability is switched off this path does not exist at all (404).
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant whose billing status to read. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
header Parameters
| Idempotency-Key | string <= 255 characters Optional client-supplied key that makes this mutating request safe to retry. The FIRST request runs and its outcome is recorded keyed by |
Responses
Production
Response samples
- 200
{- "replacementId": "58669121-2b56-4cbf-a20c-68f278d40aa3"
}Accept the currently published legal documents
Records that this account accepts the named documents, and answers with the post-state for every document this platform asks accounts to accept — not only the ones named, so an account that accepted one of two still sees the other outstanding rather than reading the answer as completion.
The version in the body is a confirmation of what was on screen, not the value recorded: the version written is the one read as current inside the same transaction, so a stale tab cannot conclude an agreement to a version nobody saw. A version that is no longer current is refused and nothing is written.
Accepting a version already held is an ordinary success. No second record is made and no second confirmation is sent.
Who accepted, when, and the counterparty as it was shown at the time are taken from the verified session and from the account; there are no fields for them. The accepting person must be named on their identity token.
⛔ All or nothing: if any named document is refused, none is recorded.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant being managed. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
Request Body schema: application/jsonrequired
required | Array of objects non-empty Each document at most once. The version must be the one currently published for that document. |
Responses
Production
Request samples
- Payload
{- "documents": [
- {
- "document": "string",
- "version": "string"
}
]
}Response samples
- 200
- 400
- 409
{- "documents": [
- {
- "document": "string",
- "outstanding": true,
- "currentVersion": "string",
- "acceptedVersion": "string",
- "acceptedAt": "2019-08-24T14:15:22Z"
}
]
}Retrieve the record of an accepted legal document
Returns the conclusion document for one legal document this account has accepted: the version, the instant with its time zone, the accepting person by name and e-mail, their role, the counterparty as it was shown at the time, and the acceptance sentence in the wording of that version.
Available at any time to an owner or admin, so a customer who deleted the confirmation e-mail — or whose accepting colleague has left — never has to ask for it. member and readonly are refused: the record names a colleague and carries their address.
The bytes are the same record the confirmation e-mail carries. It is a self-contained HTML document with no scripts and no external resources, so it can be saved, printed and reopened unchanged.
Values are those recorded AT the acceptance and are never re-resolved: a later change to a person's details does not alter a record of what they declared, and a newer published version does not alter the wording of an earlier acceptance.
Authorizations:
path Parameters
| tenantId required | string <uuid> Must be the caller's active tenant. |
| document required | string Which document, e.g. |
Responses
Production
Response samples
- 400
- 404
{- "error": "validation_error",
- "issues": [
- {
- "path": "string",
- "message": "string"
}
]
}Which legal documents this account still has to accept
One answer covering every document, so an account owing more than one meets a single prompt rather than a sequence of them.
outstanding is the whole question: true when the currently published version has not been accepted. When a newer version has superseded the one this account agreed to, outstanding is true AND acceptedVersion still names the older one — both are needed to say that the agreement has changed rather than that nothing was ever agreed.
When nothing is published, currentVersion is null and outstanding is false for everyone: there is no requirement, so there is nothing to ask about.
⛔ This is a read, not a gate. Nothing is refused on the strength of it — an act that requires an acceptance checks for itself. Any active member may read it, including a read-only one; whether to PROMPT is a separate decision for the application showing it.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant being managed. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
Responses
Production
Response samples
- 200
{- "documents": [
- {
- "document": "string",
- "outstanding": true,
- "currentVersion": "string",
- "acceptedVersion": "string",
- "acceptedAt": "2019-08-24T14:15:22Z"
}
]
}The self-serve plan catalog (unauth-safe)
The public plan/pricing catalog, served VERBATIM from the PLAN_CATALOG_V1 code constant with its entitlements + provisional price points. UNAUTHENTICATED-SAFE — pure marketing/onboarding data with NO tenant data, mandate, or usage — so it is an explicit exact-path carve-out of the default-deny gate and is served UNCONDITIONALLY (it does NOT require billing to be enabled; the catalog is always readable). Every price carries provisional: true (the structural Richtwert guardrail) — no €-figure originates in the FE.
Returns the self-serve tiers only: the plan this endpoint tells you about is the plan you can actually buy. A tier that is not self-serve is still a valid plan_tier value, still resolvable, and still billed normally for tenants already on it — it simply is not offered here and is not offerable at checkout. Consumers must therefore treat tier as an open enum on read and must not assume every catalog tier appears.
Responses
Production
Response samples
- 200
{- "catalogVersion": "v1",
- "plans": [
- {
- "tier": "launch",
- "label": "string",
- "entitlements": {
- "includedDocumentsPerPeriod": 0
}, - "price": {
- "provisional": true,
- "currency": "EUR",
- "baseMonthlyMinor": 0,
- "overagePerDocumentMinor": 0,
- "capMonthlyMinor": 0
}, - "supportResponseClass": "community",
- "supportResponseNote": "string"
}
]
}Inbound payment-provider webhook receiver (POC)
SIGNATURE-authed inbound receiver for our billing provider's payment events. NOT OIDC / not a tenant header — the caller is the provider, so this is an explicit carve-out of the default-deny gate, authenticated by the provider's HMAC-SHA256 signature over the RAW body (Webhook-Signature + Webhook-Timestamp + Webhook-Id). Verifies BEFORE any parse; dedupes on the (provider, provider_event_id) natural key (a redelivery is a 200 no-op); on a verified charge.settled flips our invoice issued → paid in one transaction; ALWAYS 200s a well-formed known event so the provider stops retrying. Mounted only when billing; the only wired provider is the mock (no real money).
header Parameters
| Webhook-Id required | string Stable per-message id, identical across every retry of the same message. It is an HMAC input, so it cannot be altered in transit; dedupe on it. |
| Webhook-Signature required | string
|
| Webhook-Timestamp required | string Unix seconds. It is INSIDE the signature, so a captured message cannot be re-stamped and replayed — reject anything outside your tolerance (we use 5 minutes). |
Request Body schema: application/jsonrequired
Provider-native event body (metadata-only).
Responses
Production
Request samples
- Payload
{ }Whether the tenant has ≥1 usable live API key
A boolean signal the dashboard reads during session-mode resolution to pick a fresh session's default (Live vs Test). Open to every active member (no requireRole) and any valid credential; returns only the boolean, no key material.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant the key belongs to. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
Responses
Production
Response samples
- 200
- 403
{- "hasLiveKey": true
}List the tenant's API keys (masked)
Admin/owner only (role-gated; a capability scope for key management is not offered on this route). Returns MASKED keys only: the plaintext is NEVER retrievable after issuance (reveal-once, unlike webhook secrets). Each key carries its displayPrefix, scopes, mode, derived status, and lastUsedAt.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant the key belongs to. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
Responses
Production
Response samples
- 200
{- "data": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "mode": "live",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "displayPrefix": "sk_live_a1b2c3d4",
- "scopes": [
- "documents:write"
], - "status": "active",
- "createdAt": "2019-08-24T14:15:22Z",
- "expiresAt": "2019-08-24T14:15:22Z",
- "lastUsedAt": "2019-08-24T14:15:22Z",
- "revokedAt": "2019-08-24T14:15:22Z",
- "suspendedAt": "2019-08-24T14:15:22Z",
- "rotatedFrom": "67ba7042-12ba-4597-bf4f-dd156cb996c3",
- "staleOverlapWarning": true
}
]
}Issue an API key
Mints a key and returns the full plaintext EXACTLY ONCE (key) in this 201 body — it is never stored (only its SHA-256 hash) and never retrievable again (genuine reveal-once, contrast webhook secrets which are encrypted-and-retrievable). mode is REQUIRED (live/test) and permanently fixes the key's mode and its sk_live_/sk_test_ prefix — the one place mode comes from request input. entityId is optional (omitted = full tenant scope; must belong to the tenant). scopes optional, defaults to the full 8-scope set. Honors the optional Idempotency-Key header so a retried issuance replays the recorded 201 rather than minting a SECOND active credential.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant the key belongs to. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
header Parameters
| Idempotency-Key | string <= 255 characters Optional client-supplied key that makes this mutating request safe to retry. The FIRST request runs and its outcome is recorded keyed by |
Request Body schema: application/jsonrequired
| name required | string [ 1 .. 200 ] characters |
| mode required | string Enum: "live" "test" Required. Permanently fixes the key's mode and prefix. |
| entityId | string <uuid> Optional; omitted = full tenant scope. Must be an entity of the tenant (else 400). |
| scopes | Array of strings (ApiKeyScope) non-empty Items Enum: "documents:write" "documents:read" "customers:write" "customers:read" "subscriptions:write" "subscriptions:read" "archive:read" "webhooks:manage" "entities:read" "entities:write" "validation:run" "sandbox:write" "payments:write" "payments:read" Optional; defaults to the full 8-scope set when omitted. |
| expiresAt | string <date-time> Optional expiry; must be in the future. Verification rejects the key past it. |
Responses
Production
Request samples
- Payload
{- "name": "string",
- "mode": "live",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "scopes": [
- "documents:write"
], - "expiresAt": "2019-08-24T14:15:22Z"
}Response samples
- 201
- 402
- 409
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "mode": "live",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "displayPrefix": "sk_live_a1b2c3d4",
- "scopes": [
- "documents:write"
], - "status": "active",
- "createdAt": "2019-08-24T14:15:22Z",
- "expiresAt": "2019-08-24T14:15:22Z",
- "lastUsedAt": "2019-08-24T14:15:22Z",
- "revokedAt": "2019-08-24T14:15:22Z",
- "suspendedAt": "2019-08-24T14:15:22Z",
- "rotatedFrom": "67ba7042-12ba-4597-bf4f-dd156cb996c3",
- "staleOverlapWarning": true,
- "key": "sk_live_a1b2c3d4e5f6…"
}Rotate a key
Mints a SUCCESSOR key (fresh secret) carrying the predecessor's mode, entity, scopes, and name, with rotatedFrom linking the pair. The old key is NOT auto-revoked (no forced overlap window — the customer revokes it on their own schedule; staleOverlapWarning nudges them if they keep using it). Returns the new plaintext ONCE (201).
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant the key belongs to. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
| id required | string <uuid> API key id. |
Responses
Production
Response samples
- 201
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "mode": "live",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "displayPrefix": "sk_live_a1b2c3d4",
- "scopes": [
- "documents:write"
], - "status": "active",
- "createdAt": "2019-08-24T14:15:22Z",
- "expiresAt": "2019-08-24T14:15:22Z",
- "lastUsedAt": "2019-08-24T14:15:22Z",
- "revokedAt": "2019-08-24T14:15:22Z",
- "suspendedAt": "2019-08-24T14:15:22Z",
- "rotatedFrom": "67ba7042-12ba-4597-bf4f-dd156cb996c3",
- "staleOverlapWarning": true,
- "key": "sk_live_a1b2c3d4e5f6…"
}Revoke a key immediately
Permanent. Rejected by api's own verification on the very next request (immediate at the api layer, independent of any gateway cache). Idempotent — revoking an already-revoked key returns it unchanged. revoked_at never clears (contrast suspension).
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant the key belongs to. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
| id required | string <uuid> API key id. |
Responses
Production
Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "mode": "live",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "displayPrefix": "sk_live_a1b2c3d4",
- "scopes": [
- "documents:write"
], - "status": "active",
- "createdAt": "2019-08-24T14:15:22Z",
- "expiresAt": "2019-08-24T14:15:22Z",
- "lastUsedAt": "2019-08-24T14:15:22Z",
- "revokedAt": "2019-08-24T14:15:22Z",
- "suspendedAt": "2019-08-24T14:15:22Z",
- "rotatedFrom": "67ba7042-12ba-4597-bf4f-dd156cb996c3",
- "staleOverlapWarning": true
}Suspend a key (operator-driven, reversible)
Same immediate rejection as revoke, but REVERSIBLE (suspended_at, distinct from revoked_at) — for abuse / non-payment hard-blocks ("never a quota, always a suspension"). Idempotent.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant the key belongs to. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
| id required | string <uuid> API key id. |
Responses
Production
Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "mode": "live",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "displayPrefix": "sk_live_a1b2c3d4",
- "scopes": [
- "documents:write"
], - "status": "active",
- "createdAt": "2019-08-24T14:15:22Z",
- "expiresAt": "2019-08-24T14:15:22Z",
- "lastUsedAt": "2019-08-24T14:15:22Z",
- "revokedAt": "2019-08-24T14:15:22Z",
- "suspendedAt": "2019-08-24T14:15:22Z",
- "rotatedFrom": "67ba7042-12ba-4597-bf4f-dd156cb996c3",
- "staleOverlapWarning": true
}Clear a key's suspension
Reverses a suspension so the key verifies again. Idempotent — a non-suspended key returns unchanged. A revoked key stays revoked (revocation is not clearable).
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant the key belongs to. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
| id required | string <uuid> API key id. |
Responses
Production
Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "mode": "live",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "displayPrefix": "sk_live_a1b2c3d4",
- "scopes": [
- "documents:write"
], - "status": "active",
- "createdAt": "2019-08-24T14:15:22Z",
- "expiresAt": "2019-08-24T14:15:22Z",
- "lastUsedAt": "2019-08-24T14:15:22Z",
- "revokedAt": "2019-08-24T14:15:22Z",
- "suspendedAt": "2019-08-24T14:15:22Z",
- "rotatedFrom": "67ba7042-12ba-4597-bf4f-dd156cb996c3",
- "staleOverlapWarning": true
}Revoke a key (REST alias for /revoke)
Idiomatic-DELETE alias of POST /revoke — same permanent revocation, returns 204.
Authorizations:
path Parameters
| tenantId required | string <uuid> The tenant the key belongs to. Must equal the caller's active tenant (the resolved auth context) — a mismatch is 403 |
| id required | string <uuid> API key id. |
Responses
Production
List webhook endpoints for the active tenant/entity
Admin/owner only (requireTenantContext + requireRole('admin')). Bounded, small set per tenant. Secrets are never returned here — only the masked secretPrefix. Each item also carries lastDeliveredAt, the last successful delivery instant.
Authorizations:
Responses
Production
Response samples
- 200
{- "data": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "mode": "live",
- "url": "string",
- "subscribedTypes": [
- "issued"
], - "status": "active",
- "secretPrefix": "whsec_a1b2c3d4",
- "secretRotatedAt": "2019-08-24T14:15:22Z",
- "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "lastDeliveredAt": "2019-08-24T14:15:22Z"
}
]
}Register a webhook endpoint
Creates an endpoint and returns it WITH the full signing secret exactly once (secret, prominence: show-once, but also retrievable later via /reveal-secret). mode is fixed at creation to the caller's active auth-context mode and is never mutable afterward; supplying a different mode (or a non-active entityId) is a 400. url must be HTTPS. Honors the optional Idempotency-Key header so a retried registration is a replay, not a second endpoint minting a second signing secret (the same duplicate-is-dangerous case as POST /tenants/{tenantId}/api-keys).
Authorizations:
header Parameters
| Idempotency-Key | string <= 255 characters Optional client-supplied key that makes this mutating request safe to retry. The FIRST request runs and its outcome is recorded keyed by |
Request Body schema: application/jsonrequired
| url required | string HTTPS-only receiver URL (non-HTTPS rejected 400). |
| subscribedTypes required | Array of strings (WebhookEventCode) non-empty Items Enum: "issued" "transmission_accepted" "transmission_rejected" "archived" "invoice_paid" "payment_reconciled" "payment_partial" "payment_reversed" |
| mode | string Enum: "live" "test" Optional; must equal the caller's active mode (else 400). Fixed at creation. |
| entityId | string <uuid> Optional; must equal the caller's active entity (else 400). |
Responses
Production
Request samples
- Payload
{- "subscribedTypes": [
- "issued"
], - "mode": "live",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5"
}Response samples
- 201
- 409
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "mode": "live",
- "url": "string",
- "subscribedTypes": [
- "issued"
], - "status": "active",
- "secretPrefix": "whsec_a1b2c3d4",
- "secretRotatedAt": "2019-08-24T14:15:22Z",
- "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "secret": "whsec_a1b2c3d4e5f6…"
}Get one webhook endpoint (masked secret)
Authorizations:
path Parameters
| id required | string <uuid> Webhook endpoint id. |
Responses
Production
Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "mode": "live",
- "url": "string",
- "subscribedTypes": [
- "issued"
], - "status": "active",
- "secretPrefix": "whsec_a1b2c3d4",
- "secretRotatedAt": "2019-08-24T14:15:22Z",
- "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Update a webhook endpoint (url / subscribedTypes / status)
mode is NOT mutable — a body containing mode (or any unknown field) is rejected 400. Every successful update writes an audit-log entry.
Authorizations:
path Parameters
| id required | string <uuid> Webhook endpoint id. |
Request Body schema: application/jsonrequired
non-empty | url | string HTTPS-only. |
| subscribedTypes | Array of strings (WebhookEventCode) non-empty Items Enum: "issued" "transmission_accepted" "transmission_rejected" "archived" "invoice_paid" "payment_reconciled" "payment_partial" "payment_reversed" |
| status | string Enum: "active" "disabled" |
Responses
Production
Request samples
- Payload
{- "url": "string",
- "subscribedTypes": [
- "issued"
], - "status": "active"
}Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "mode": "live",
- "url": "string",
- "subscribedTypes": [
- "issued"
], - "status": "active",
- "secretPrefix": "whsec_a1b2c3d4",
- "secretRotatedAt": "2019-08-24T14:15:22Z",
- "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Disable a webhook endpoint (soft-delete)
Soft-delete: flips status='disabled' so the delivery log stays referentially intact. Audit-logged.
Authorizations:
path Parameters
| id required | string <uuid> Webhook endpoint id. |
Responses
Production
Reveal the full signing secret (audit-logged, repeatable)
Decrypts and returns the full plaintext signing secret (the secret is retrievable, not hash-only). Writes an audit-log entry on EVERY call (a repeatable toggle, not show-once).
Authorizations:
path Parameters
| id required | string <uuid> Webhook endpoint id. |
Responses
Production
Response samples
- 200
{- "secret": "whsec_a1b2c3d4…"
}Rotate the signing secret
Generates a new signing secret and returns it once; the old secret stops validating immediately (no overlap window). Audit-logged.
Authorizations:
path Parameters
| id required | string <uuid> Webhook endpoint id. |
Responses
Production
Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "mode": "live",
- "url": "string",
- "subscribedTypes": [
- "issued"
], - "status": "active",
- "secretPrefix": "whsec_a1b2c3d4",
- "secretRotatedAt": "2019-08-24T14:15:22Z",
- "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "secret": "whsec_a1b2c3d4e5f6…"
}Send a synthetic test event (async, "Testereignis senden")
Enqueues a synthetic=true event (document_id=null, representative metadata-only payload) and the SINGLE delivery targeting ONLY this endpoint, dispatched through the normal delivery worker (signed identically to a real delivery). Returns 202 with the delivery id to poll (GET /webhook-deliveries/{id}) — it does NOT POST synchronously. eventCode must be in the endpoint's subscribed set (else 400 event_not_subscribed). Synthetic events are never metered / never counted as a lifecycle transition. Audit-logged.
Authorizations:
path Parameters
| id required | string <uuid> Webhook endpoint id. |
Request Body schema: application/jsonrequired
| eventCode required | string (WebhookEventCode) Enum: "issued" "transmission_accepted" "transmission_rejected" "archived" "invoice_paid" "payment_reconciled" "payment_partial" "payment_reversed" A STABLE internal event code (never the wire label). The dashboard maps these to display labels via ⭐ The payment family is a SEQUENCE, not a set of terminal flags. The document-lifecycle codes ( |
Responses
Production
Request samples
- Payload
{- "eventCode": "issued"
}Response samples
- 202
{- "deliveryId": "73dc828d-801d-4d29-b7e4-e046662a5901",
- "eventId": "d6703cc8-9e79-415d-ac03-a4dc7f6ab43c",
- "synthetic": true,
- "replayOf": "f2f6a5b2-100f-405e-a1f2-e1612a376e2a"
}Per-endpoint delivery log (cursor-paginated)
Cursor-paginated ({ data, nextCursor }), keyset over (created_at, id), newest first. Metadata only — no request/response bodies. 404 if the endpoint is not visible to the caller.
Authorizations:
path Parameters
| id required | string <uuid> Webhook endpoint id. |
query Parameters
| cursor | string Opaque keyset cursor from a prior page's |
| limit | integer [ 1 .. 100 ] Default: 25 Page size for cursor-paginated reads. |
| eventCode | string (WebhookEventCode) Enum: "issued" "transmission_accepted" "transmission_rejected" "archived" "invoice_paid" "payment_reconciled" "payment_partial" "payment_reversed" Filter by the STABLE event code. |
| status | string^(pending|delivering|succeeded|retrying|faile... Example: status=pending,delivering,retrying Filter by delivery status. A single value exact-matches; a comma-separated set (e.g. |
| from | string <date-time> Inclusive |
| to | string <date-time> Inclusive |
Responses
Production
Response samples
- 200
- 400
{- "data": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "mode": "live",
- "endpointId": "c8d2c7e1-e4b1-4108-9e29-3429a36a1ef3",
- "eventId": "d6703cc8-9e79-415d-ac03-a4dc7f6ab43c",
- "eventCode": "issued",
- "eventLabel": "sent",
- "synthetic": true,
- "status": "pending",
- "attemptCount": 0,
- "lastResponseStatus": 0,
- "lastResponseMs": 0,
- "nextRetryAt": "2019-08-24T14:15:22Z",
- "firstAttemptedAt": "2019-08-24T14:15:22Z",
- "completedAt": "2019-08-24T14:15:22Z",
- "replayOf": "f2f6a5b2-100f-405e-a1f2-e1612a376e2a",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}
], - "nextCursor": "string"
}Tenant-wide webhook delivery log (cursor-paginated)
Cursor-paginated latest-state delivery log across the tenant's endpoints. Metadata only, no bodies. Served from the parent row — no join. Filter by endpointId/eventCode/status/date range.
Authorizations:
query Parameters
| cursor | string Opaque keyset cursor from a prior page's |
| limit | integer [ 1 .. 100 ] Default: 25 Page size for cursor-paginated reads. |
| endpointId | string <uuid> Restrict to one endpoint's deliveries. |
| eventCode | string (WebhookEventCode) Enum: "issued" "transmission_accepted" "transmission_rejected" "archived" "invoice_paid" "payment_reconciled" "payment_partial" "payment_reversed" Filter by the STABLE event code. |
| status | string^(pending|delivering|succeeded|retrying|faile... Example: status=pending,delivering,retrying Filter by delivery status. A single value exact-matches; a comma-separated set (e.g. |
| from | string <date-time> Inclusive |
| to | string <date-time> Inclusive |
Responses
Production
Response samples
- 200
- 400
{- "data": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "mode": "live",
- "endpointId": "c8d2c7e1-e4b1-4108-9e29-3429a36a1ef3",
- "eventId": "d6703cc8-9e79-415d-ac03-a4dc7f6ab43c",
- "eventCode": "issued",
- "eventLabel": "sent",
- "synthetic": true,
- "status": "pending",
- "attemptCount": 0,
- "lastResponseStatus": 0,
- "lastResponseMs": 0,
- "nextRetryAt": "2019-08-24T14:15:22Z",
- "firstAttemptedAt": "2019-08-24T14:15:22Z",
- "completedAt": "2019-08-24T14:15:22Z",
- "replayOf": "f2f6a5b2-100f-405e-a1f2-e1612a376e2a",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}
], - "nextCursor": "string"
}One delivery + its ordered attempt timeline
Returns the delivery latest-state PLUS its attempts[] (oldest→newest), which backs the per-attempt timeline. Metadata only.
Authorizations:
path Parameters
| id required | string <uuid> Delivery id. |
Responses
Production
Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
- "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
- "mode": "live",
- "endpointId": "c8d2c7e1-e4b1-4108-9e29-3429a36a1ef3",
- "eventId": "d6703cc8-9e79-415d-ac03-a4dc7f6ab43c",
- "eventCode": "issued",
- "eventLabel": "sent",
- "synthetic": true,
- "status": "pending",
- "attemptCount": 0,
- "lastResponseStatus": 0,
- "lastResponseMs": 0,
- "nextRetryAt": "2019-08-24T14:15:22Z",
- "firstAttemptedAt": "2019-08-24T14:15:22Z",
- "completedAt": "2019-08-24T14:15:22Z",
- "replayOf": "f2f6a5b2-100f-405e-a1f2-e1612a376e2a",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "attempts": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "attemptNo": 0,
- "responseStatus": 0,
- "responseMs": 0,
- "outcome": "succeeded",
- "errorCategory": "timeout",
- "attemptedAt": "2019-08-24T14:15:22Z"
}
]
}Replay a delivery (async, "Erneut senden")
Creates a NEW delivery record, linked to the original, referencing the same frozen event + endpoint, pending with no attempts yet, dispatched through the normal delivery worker. The original delivery and its attempt history stay IMMUTABLE; the replayed body is byte-identical (re-signed with a fresh timestamp — legally safe per UStAE 14c.1 Abs. 4 Satz 7). Returns 202 with the new delivery id to poll. Audit-logged.
Authorizations:
path Parameters
| id required | string <uuid> The original delivery id to replay. |
Responses
Production
Response samples
- 202
{- "deliveryId": "73dc828d-801d-4d29-b7e4-e046662a5901",
- "eventId": "d6703cc8-9e79-415d-ac03-a4dc7f6ab43c",
- "synthetic": true,
- "replayOf": "f2f6a5b2-100f-405e-a1f2-e1612a376e2a"
}Seed the caller's sandbox (test mode) with a deterministic German dataset.
Sandbox Time Machine. Seeds a mid-cycle monthly subscription + a scheduled (deferred) invoice + a customer and sets the test clock to a fixed anchor, so a subsequent POST /sandbox/test-clock/advance materializes deterministic billing/issuance runs. Idempotent (a re-seed adds no duplicate). TEST MODE ONLY — a live key/session is rejected 409 sandbox_test_mode_only. Behind the sandbox time machine capability (default off ⇒ 404). Runs as an async job (202 + poll /sandbox/jobs/:id).
Authorizations:
Responses
Production
Response samples
- 202
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "kind": "seed",
- "state": "requested",
- "mode": "test",
- "toDate": "2019-08-24",
- "result": { },
- "lastError": "string",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Fast-forward the sandbox clock, materializing all due runs.
Fast-forward the sandbox clock to toDate, materializing all due subscription-billing and deferred-invoice runs (and their lifecycle webhooks) deterministically via the SAME idempotent run-billing/issue orchestrators the scheduling workers use — with an injected asOf in place of wall-clock. Forward-only. TEST MODE ONLY.
Authorizations:
Request Body schema: application/jsonrequired
| toDate required | string <date> |
Responses
Production
Request samples
- Payload
{- "toDate": "2019-08-24"
}Response samples
- 202
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "kind": "seed",
- "state": "requested",
- "mode": "test",
- "toDate": "2019-08-24",
- "result": { },
- "lastError": "string",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Reset the sandbox to empty (optionally re-seed). TEST MODE ONLY.
RLS-scoped wipe of the caller's deletable (entity, mode='test') fixtures + reset the clock to its anchor. Issued compliance documents are append-only (mode-independent) and are NOT deleted. Optional { reseed: true } re-seeds after the wipe. Live data can NEVER be reset by this path.
Authorizations:
Request Body schema: application/jsonoptional
| reseed | boolean Default: false |
Responses
Production
Request samples
- Payload
{- "reseed": false
}Response samples
- 202
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "kind": "seed",
- "state": "requested",
- "mode": "test",
- "toDate": "2019-08-24",
- "result": { },
- "lastError": "string",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Poll a sandbox seed/advance/reset job. TEST MODE ONLY.
requested → running → done|failed plus the materialized document/webhook sequence in result. RLS-scoped: a cross-entity/mode job id is a 404.
Authorizations:
path Parameters
| id required | string <uuid> |
Responses
Production
Response samples
- 200
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "kind": "seed",
- "state": "requested",
- "mode": "test",
- "toDate": "2019-08-24",
- "result": { },
- "lastError": "string",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}- ← Quickstart — der kürzeste Weg zur ersten Rechnung
- Sandbox & API-Keys — live: Test-Keys und vollständige Sandbox