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:

License: Apache-2.0

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.

What this base URL accepts

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.

Rate limiting

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.

Rechnungen

Create, issue, and manage invoices, credit notes, and their PDF/XML artifacts and validation.

List invoice summaries (newest first, paginated)

Authorizations:
OidcBearerApiKeyBearer
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. void is RESERVED AND NEVER WRITTEN: §14c UStG / GoBD forbid voiding an original — a cancellation is a separate 381 referencing it — so ?status=void is accepted and returns an EMPTY page rather than a 400. To find cancelled documents use ?correctionState=cancelled.

correctionState
string
Enum: "cancelled" "corrected" "none"

Filter by the DERIVED correction state — computed from the document chain (the preceding-invoice link), never stored. cancelled = an issued/paid 381 (Storno) references the invoice; corrected = an issued/paid 384 (Berichtigung) does and no 381 does; none = neither. A DRAFT correction counts as none — it has cancelled nothing yet. The three values partition the result set. Omit for unchanged behaviour (no correction filtering).

validationState
string
Enum: "failed" "passed" "legacy"

Compliance-gate filter. failed surfaces documents (including scheduled invoices that reverted to draft) whose last issue attempt had an error-severity finding — the mitigation for scheduled issue failures having no other human in the loop.

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 — transmissionState undeliverable (the receiving mail system refused it permanently) or failed (nothing was ever handed over). This is deliberately a boolean rather than a transmissionState filter: the other states describe our own queue, which is an internal fact that changes meaning whenever the sending limits change. Only the literal value true is accepted; false is not a filter, it is the unfiltered list.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "pagination": {
    }
}

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:
OidcBearerApiKeyBearer
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 (tenant, route, key); a REPLAY (same key + byte-identical body, after the first completed) returns the recorded response verbatim WITHOUT re-executing — so a network-blip retry never creates a duplicate customer/subscription/key. A key reused with a DIFFERENT body is 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY; a key whose first request is still in flight is 409 REQUEST_IN_PROGRESS (retryable). Keys expire after 24h — a key reused after expiry is a fresh request, not a replay. Transparent when omitted (the request runs normally).

Request Body schema: application/json
required
currency
required
string

ISO 4217 (3 letters)

invoiceTypeCode
string

UNTDID 1001 (BT-3). Defaults to "380" if omitted. Allowlist- checked against INVOICE_TYPE_CODES (default "380,381,384") — an unlisted code is a 400 validation_error.

object

EN 16931 BG-3 reference. REQUIRED when invoiceTypeCode="384" (correction); a missing, dangling, draft/scheduled, or otherwise unresolvable reference is a 422 INVALID_PRECEDING_REFERENCE. Ignored for any other invoiceTypeCode. Resolved server-side by id or invoiceNumber (at least one required) — the response's precedingInvoice.issueDate/invoiceNumber come from the resolved original, not from this input.

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 buyerEmail when absent; the format is validated at XRechnung generation time, not here.

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 null means "none — and do not apply the default".

text
string or null <= 5000 characters

Free introductory body text rendered above the line items. Distinct from notes, which renders below them. On a create, OMITTING the field applies the company-wide default text; an explicit null means "none — and do not apply the default".

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 null means "none — and do not apply the default".

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 billingPeriodStart/billingPeriodEnd (BT-72 XOR BG-14) — provide a single date OR a period, not both. Null/omitted → the renderer keeps its issueDate fallback. Frozen at issue.

isDauerrechnung
boolean

Mark this invoice as a Dauerrechnung (continuous-supply invoice, UStAE 14.5 Abs. 17 self-identifying branch). Orthogonal to invoiceTypeCode — the code stays "380" (or "384" on a reissue-as-Berichtigung referencing the source via precedingInvoice). Reuses billingPeriodStart/billingPeriodEnd for the Nr. 6 Leistungszeitraum and invoiceNo for the static Objektnummer. Defaults false; frozen at issue. Reissue is a fresh create+finalize, never a server-side action (no anchor table, no reissue-classification endpoint in v1).

required
Array of objects (CreateInvoiceLineItem) non-empty

Responses

Request samples

Content type
application/json
{
  • "currency": "EUR",
  • "invoiceTypeCode": "380",
  • "buyer": {
    },
  • "buyerReference": "FP-2026-0219",
  • "deliveryDate": "2026-07-31",
  • "lineItems": [
    ]
}

Response samples

Content type
application/json
{
  • "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": {
    },
  • "transmission": {
    },
  • "capabilities": {
    },
  • "dueDate": "2019-08-24",
  • "paymentTerms": "string",
  • "invoiceTypeCode": "380",
  • "reversalScope": "full",
  • "precedingInvoice": {
    },
  • "correctedBy": [
    ],
  • "sourceOfferId": "27f452e8-4c83-478a-af96-74a338a701ab",
  • "sourceOffer": {
    },
  • "correctionDocuments": [
    ],
  • "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
  • "documentTarget": "b2b",
  • "seller": {
    },
  • "sellerIsKleinunternehmer": true,
  • "isDauerrechnung": true,
  • "dauerrechnungState": "active",
  • "buyer": {
    },
  • "buyerReference": "string",
  • "buyerEmail": "user@example.com",
  • "notes": "string",
  • "lineItems": [
    ],
  • "totals": {
    },
  • "taxBreakdown": [
    ],
  • "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:
OidcBearerApiKeyBearer
Request Body schema: application/json
required
required
Array of objects (CreateInvoiceLineItem) non-empty

Responses

Request samples

Content type
application/json
{
  • "lineItems": [
    ]
}

Response samples

Content type
application/json
{
  • "totals": {
    },
  • "taxBreakdown": [
    ],
  • "lines": [
    ]
}

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:
OidcBearerApiKeyBearer
query Parameters
issueDate
string <date>

ISO date (YYYY-MM-DD) the number's date segment would use. Defaults to today.

Responses

Response samples

Content type
application/json
{
  • "nextNumber": "string"
}

Get a full invoice by id

Authorizations:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "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": {
    },
  • "transmission": {
    },
  • "capabilities": {
    },
  • "dueDate": "2019-08-24",
  • "paymentTerms": "string",
  • "invoiceTypeCode": "380",
  • "reversalScope": "full",
  • "precedingInvoice": {
    },
  • "correctedBy": [
    ],
  • "sourceOfferId": "27f452e8-4c83-478a-af96-74a338a701ab",
  • "sourceOffer": {
    },
  • "correctionDocuments": [
    ],
  • "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
  • "documentTarget": "b2b",
  • "seller": {
    },
  • "sellerIsKleinunternehmer": true,
  • "isDauerrechnung": true,
  • "dauerrechnungState": "active",
  • "buyer": {
    },
  • "buyerReference": "string",
  • "buyerEmail": "user@example.com",
  • "notes": "string",
  • "lineItems": [
    ],
  • "totals": {
    },
  • "taxBreakdown": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>
Request Body schema: application/json
required
currency
required
string

ISO 4217 (3 letters)

invoiceTypeCode
string

UNTDID 1001 (BT-3). Defaults to "380" if omitted. Allowlist- checked against INVOICE_TYPE_CODES (default "380,381,384") — an unlisted code is a 400 validation_error.

object

EN 16931 BG-3 reference. REQUIRED when invoiceTypeCode="384" (correction); a missing, dangling, draft/scheduled, or otherwise unresolvable reference is a 422 INVALID_PRECEDING_REFERENCE. Ignored for any other invoiceTypeCode. Resolved server-side by id or invoiceNumber (at least one required) — the response's precedingInvoice.issueDate/invoiceNumber come from the resolved original, not from this input.

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 buyerEmail when absent; the format is validated at XRechnung generation time, not here.

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 null means "none — and do not apply the default".

text
string or null <= 5000 characters

Free introductory body text rendered above the line items. Distinct from notes, which renders below them. On a create, OMITTING the field applies the company-wide default text; an explicit null means "none — and do not apply the default".

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 null means "none — and do not apply the default".

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 billingPeriodStart/billingPeriodEnd (BT-72 XOR BG-14) — provide a single date OR a period, not both. Null/omitted → the renderer keeps its issueDate fallback. Frozen at issue.

isDauerrechnung
boolean

Mark this invoice as a Dauerrechnung (continuous-supply invoice, UStAE 14.5 Abs. 17 self-identifying branch). Orthogonal to invoiceTypeCode — the code stays "380" (or "384" on a reissue-as-Berichtigung referencing the source via precedingInvoice). Reuses billingPeriodStart/billingPeriodEnd for the Nr. 6 Leistungszeitraum and invoiceNo for the static Objektnummer. Defaults false; frozen at issue. Reissue is a fresh create+finalize, never a server-side action (no anchor table, no reissue-classification endpoint in v1).

required
Array of objects (CreateInvoiceLineItem) non-empty

Responses

Request samples

Content type
application/json
{
  • "currency": "string",
  • "invoiceTypeCode": "380",
  • "precedingInvoice": {
    },
  • "issueDate": "2019-08-24",
  • "dueDate": "2019-08-24",
  • "paymentTerms": "string",
  • "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
  • "documentTarget": "b2b",
  • "status": "draft",
  • "buyer": {
    },
  • "seller": {
    },
  • "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": [
    ]
}

Response samples

Content type
application/json
{
  • "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": {
    },
  • "transmission": {
    },
  • "capabilities": {
    },
  • "dueDate": "2019-08-24",
  • "paymentTerms": "string",
  • "invoiceTypeCode": "380",
  • "reversalScope": "full",
  • "precedingInvoice": {
    },
  • "correctedBy": [
    ],
  • "sourceOfferId": "27f452e8-4c83-478a-af96-74a338a701ab",
  • "sourceOffer": {
    },
  • "correctionDocuments": [
    ],
  • "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
  • "documentTarget": "b2b",
  • "seller": {
    },
  • "sellerIsKleinunternehmer": true,
  • "isDauerrechnung": true,
  • "dauerrechnungState": "active",
  • "buyer": {
    },
  • "buyerReference": "string",
  • "buyerEmail": "user@example.com",
  • "notes": "string",
  • "lineItems": [
    ],
  • "totals": {
    },
  • "taxBreakdown": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "error": "validation_error",
  • "issues": [
    ]
}

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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "error": "validation_error",
  • "issues": [
    ]
}

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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "error": "validation_error",
  • "issues": [
    ]
}

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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "error": "validation_error",
  • "issues": [
    ]
}

Reset a failed PDF render to pending and retry generation

Requires an issued invoice whose pdf_status is 'failed'.

Authorizations:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "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": {
    },
  • "transmission": {
    },
  • "capabilities": {
    },
  • "dueDate": "2019-08-24",
  • "paymentTerms": "string",
  • "invoiceTypeCode": "380",
  • "reversalScope": "full",
  • "precedingInvoice": {
    },
  • "correctedBy": [
    ],
  • "sourceOfferId": "27f452e8-4c83-478a-af96-74a338a701ab",
  • "sourceOffer": {
    },
  • "correctionDocuments": [
    ],
  • "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
  • "documentTarget": "b2b",
  • "seller": {
    },
  • "sellerIsKleinunternehmer": true,
  • "isDauerrechnung": true,
  • "dauerrechnungState": "active",
  • "buyer": {
    },
  • "buyerReference": "string",
  • "buyerEmail": "user@example.com",
  • "notes": "string",
  • "lineItems": [
    ],
  • "totals": {
    },
  • "taxBreakdown": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "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": {
    },
  • "transmission": {
    },
  • "capabilities": {
    },
  • "dueDate": "2019-08-24",
  • "paymentTerms": "string",
  • "invoiceTypeCode": "380",
  • "reversalScope": "full",
  • "precedingInvoice": {
    },
  • "correctedBy": [
    ],
  • "sourceOfferId": "27f452e8-4c83-478a-af96-74a338a701ab",
  • "sourceOffer": {
    },
  • "correctionDocuments": [
    ],
  • "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
  • "documentTarget": "b2b",
  • "seller": {
    },
  • "sellerIsKleinunternehmer": true,
  • "isDauerrechnung": true,
  • "dauerrechnungState": "active",
  • "buyer": {
    },
  • "buyerReference": "string",
  • "buyerEmail": "user@example.com",
  • "notes": "string",
  • "lineItems": [
    ],
  • "totals": {
    },
  • "taxBreakdown": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>
Request Body schema: application/json
required
restricted
required
boolean

true = set the restriction, false = clear it.

reason
string
Enum: "article_18_gdpr_request" "legal_dispute" "disputed_accuracy"

Required when restricted is true; ignored when clearing.

Responses

Request samples

Content type
application/json
{
  • "restricted": true,
  • "reason": "article_18_gdpr_request"
}

Response samples

Content type
application/json
{
  • "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": {
    },
  • "transmission": {
    },
  • "capabilities": {
    },
  • "dueDate": "2019-08-24",
  • "paymentTerms": "string",
  • "invoiceTypeCode": "380",
  • "reversalScope": "full",
  • "precedingInvoice": {
    },
  • "correctedBy": [
    ],
  • "sourceOfferId": "27f452e8-4c83-478a-af96-74a338a701ab",
  • "sourceOffer": {
    },
  • "correctionDocuments": [
    ],
  • "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
  • "documentTarget": "b2b",
  • "seller": {
    },
  • "sellerIsKleinunternehmer": true,
  • "isDauerrechnung": true,
  • "dauerrechnungState": "active",
  • "buyer": {
    },
  • "buyerReference": "string",
  • "buyerEmail": "user@example.com",
  • "notes": "string",
  • "lineItems": [
    ],
  • "totals": {
    },
  • "taxBreakdown": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>
Request Body schema: application/json
required
scheduledIssuanceDate
required
string <date>

Responses

Request samples

Content type
application/json
{
  • "scheduledIssuanceDate": "2019-08-24"
}

Response samples

Content type
application/json
{
  • "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": {
    },
  • "transmission": {
    },
  • "capabilities": {
    },
  • "dueDate": "2019-08-24",
  • "paymentTerms": "string",
  • "invoiceTypeCode": "380",
  • "reversalScope": "full",
  • "precedingInvoice": {
    },
  • "correctedBy": [
    ],
  • "sourceOfferId": "27f452e8-4c83-478a-af96-74a338a701ab",
  • "sourceOffer": {
    },
  • "correctionDocuments": [
    ],
  • "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
  • "documentTarget": "b2b",
  • "seller": {
    },
  • "sellerIsKleinunternehmer": true,
  • "isDauerrechnung": true,
  • "dauerrechnungState": "active",
  • "buyer": {
    },
  • "buyerReference": "string",
  • "buyerEmail": "user@example.com",
  • "notes": "string",
  • "lineItems": [
    ],
  • "totals": {
    },
  • "taxBreakdown": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "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": {
    },
  • "transmission": {
    },
  • "capabilities": {
    },
  • "dueDate": "2019-08-24",
  • "paymentTerms": "string",
  • "invoiceTypeCode": "380",
  • "reversalScope": "full",
  • "precedingInvoice": {
    },
  • "correctedBy": [
    ],
  • "sourceOfferId": "27f452e8-4c83-478a-af96-74a338a701ab",
  • "sourceOffer": {
    },
  • "correctionDocuments": [
    ],
  • "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
  • "documentTarget": "b2b",
  • "seller": {
    },
  • "sellerIsKleinunternehmer": true,
  • "isDauerrechnung": true,
  • "dauerrechnungState": "active",
  • "buyer": {
    },
  • "buyerReference": "string",
  • "buyerEmail": "user@example.com",
  • "notes": "string",
  • "lineItems": [
    ],
  • "totals": {
    },
  • "taxBreakdown": [
    ],
  • "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": {
    },
  • "section14": {
    },
  • "warnings": [
    ]
}

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:
OidcBearerApiKeyBearer
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 (tenant, route, key); a REPLAY (same key + byte-identical body, after the first completed) returns the recorded response verbatim WITHOUT re-executing — so a network-blip retry never creates a duplicate customer/subscription/key. A key reused with a DIFFERENT body is 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY; a key whose first request is still in flight is 409 REQUEST_IN_PROGRESS (retryable). Keys expire after 24h — a key reused after expiry is a fresh request, not a replay. Transparent when omitted (the request runs normally).

Responses

Response samples

Content type
application/json
{
  • "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": {
    },
  • "transmission": {
    },
  • "capabilities": {
    },
  • "dueDate": "2019-08-24",
  • "paymentTerms": "string",
  • "invoiceTypeCode": "380",
  • "reversalScope": "full",
  • "precedingInvoice": {
    },
  • "correctedBy": [
    ],
  • "sourceOfferId": "27f452e8-4c83-478a-af96-74a338a701ab",
  • "sourceOffer": {
    },
  • "correctionDocuments": [
    ],
  • "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
  • "documentTarget": "b2b",
  • "seller": {
    },
  • "sellerIsKleinunternehmer": true,
  • "isDauerrechnung": true,
  • "dauerrechnungState": "active",
  • "buyer": {
    },
  • "buyerReference": "string",
  • "buyerEmail": "user@example.com",
  • "notes": "string",
  • "lineItems": [
    ],
  • "totals": {
    },
  • "taxBreakdown": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "verdict": "pass",
  • "reports": [
    ],
  • "section14": {
    },
  • "warnings": [
    ]
}

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:
OidcBearerApiKeyBearer
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 formatVersion). Echoed in the response.

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 INVOICE_TYPE_CODES (default "380,381,384") — an unlisted code is a 400 validation_error.

object

EN 16931 BG-3 reference. REQUIRED when invoiceTypeCode="384" (correction); a missing, dangling, draft/scheduled, or otherwise unresolvable reference is a 422 INVALID_PRECEDING_REFERENCE. Ignored for any other invoiceTypeCode. Resolved server-side by id or invoiceNumber (at least one required) — the response's precedingInvoice.issueDate/invoiceNumber come from the resolved original, not from this input.

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 buyerEmail when absent; the format is validated at XRechnung generation time, not here.

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 null means "none — and do not apply the default".

text
string or null <= 5000 characters

Free introductory body text rendered above the line items. Distinct from notes, which renders below them. On a create, OMITTING the field applies the company-wide default text; an explicit null means "none — and do not apply the default".

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 null means "none — and do not apply the default".

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 billingPeriodStart/billingPeriodEnd (BT-72 XOR BG-14) — provide a single date OR a period, not both. Null/omitted → the renderer keeps its issueDate fallback. Frozen at issue.

isDauerrechnung
boolean

Mark this invoice as a Dauerrechnung (continuous-supply invoice, UStAE 14.5 Abs. 17 self-identifying branch). Orthogonal to invoiceTypeCode — the code stays "380" (or "384" on a reissue-as-Berichtigung referencing the source via precedingInvoice). Reuses billingPeriodStart/billingPeriodEnd for the Nr. 6 Leistungszeitraum and invoiceNo for the static Objektnummer. Defaults false; frozen at issue. Reissue is a fresh create+finalize, never a server-side action (no anchor table, no reissue-classification endpoint in v1).

required
Array of objects (CreateInvoiceLineItem) non-empty

Responses

Request samples

Content type
{
  • "currency": "string",
  • "invoiceTypeCode": "380",
  • "precedingInvoice": {
    },
  • "issueDate": "2019-08-24",
  • "dueDate": "2019-08-24",
  • "paymentTerms": "string",
  • "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
  • "documentTarget": "b2b",
  • "status": "draft",
  • "buyer": {
    },
  • "seller": {
    },
  • "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": [
    ]
}

Response samples

Content type
application/json
{
  • "verdict": "pass",
  • "reports": [
    ],
  • "profile": "string",
  • "section14": {
    },
  • "warnings": [
    ]
}

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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
[
  • {
    }
]

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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>
reportId
required
string <uuid>

Responses

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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "status": "passed",
  • "rulesetVersion": "1.0.0",
  • "evaluatedAt": "2019-08-24T14:15:22Z",
  • "items": [
    ]
}

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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>
header Parameters
Idempotency-Key
string
Request Body schema: application/json
optional
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

Request samples

Content type
application/json
{
  • "toEmail": "user@example.com",
  • "cc": [
    ],
  • "bcc": [
    ]
}

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

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:
OidcBearerApiKeyBearer
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/json
required
channel
required
string
Enum: "post" "handover" "external_email" "other"

How the document went out. email is not accepted here: a dispatch this application performs is recorded by sending the invoice. Use external_email for a message you sent from your own mail program.

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

Request samples

Content type
application/json
{
  • "channel": "post",
  • "sentOn": "2019-08-24",
  • "note": "string"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "invoiceId": "4f163819-178d-470c-a246-d6768476a6ec",
  • "trigger": "manual",
  • "evidence": "system_transmitted",
  • "channel": "email",
  • "toEmail": "string",
  • "cc": [
    ],
  • "bcc": [
    ],
  • "sentOn": "2019-08-24",
  • "note": "string",
  • "reversesSendId": "06c0a322-ce3e-46f2-8307-b37bf5c41306",
  • "retractionReason": "wrong_invoice",
  • "retractedAt": "2019-08-24T14:15:22Z",
  • "recordedBy": {
    },
  • "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:
OidcBearerApiKeyBearer
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/json
required
reason
required
string
Enum: "wrong_invoice" "not_actually_sent" "wrong_date" "duplicate_entry" "other"
note
string <= 280 characters

Responses

Request samples

Content type
application/json
{
  • "reason": "wrong_invoice",
  • "note": "string"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "invoiceId": "4f163819-178d-470c-a246-d6768476a6ec",
  • "trigger": "manual",
  • "evidence": "system_transmitted",
  • "channel": "email",
  • "toEmail": "string",
  • "cc": [
    ],
  • "bcc": [
    ],
  • "sentOn": "2019-08-24",
  • "note": "string",
  • "reversesSendId": "06c0a322-ce3e-46f2-8307-b37bf5c41306",
  • "retractionReason": "wrong_invoice",
  • "retractedAt": "2019-08-24T14:15:22Z",
  • "recordedBy": {
    },
  • "createdAt": "2019-08-24T14:15:22Z"
}

Angebote

Create, send, accept/decline, and convert quotes (Angebote) into invoices.

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:
OidcBearerApiKeyBearer
header Parameters
Idempotency-Key
string
Request Body schema: application/json
required
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 null means "none — and do not apply the default". On a replace, omitting it stores nothing — that is how a title is cleared.

text
string or null

Body text. On a create, omitting it takes the company-wide default, while an explicit null means "none — and do not apply the default". On a replace, omitting it stores nothing.

notes
string or null

Note. On a create, omitting it takes the company-wide default, while an explicit null means "none — and do not apply the default". On a replace, omitting it stores nothing — deleting the note and saving clears it, and the company default does not come back.

bindingExclusionNote
string
required
Array of objects (CreateOfferLineItem) non-empty

Responses

Request samples

Content type
application/json
{
  • "currency": "string",
  • "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
  • "buyer": {
    },
  • "seller": {
    },
  • "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": [
    ]
}

Response samples

Content type
application/json
{
  • "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": {
    },
  • "buyer": {
    },
  • "buyerReference": "string",
  • "buyerElectronicAddress": "string",
  • "buyerEmail": "string",
  • "title": "string",
  • "text": "string",
  • "notes": "string",
  • "bindingExclusionNote": "string",
  • "totals": {
    },
  • "lineItems": [
    ],
  • "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 validUntilexpired) 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:
OidcBearerApiKeyBearer
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 is derived — use expired).

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

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "pagination": {
    }
}

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:
OidcBearerApiKeyBearer

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "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": {
    },
  • "buyer": {
    },
  • "buyerReference": "string",
  • "buyerElectronicAddress": "string",
  • "buyerEmail": "string",
  • "title": "string",
  • "text": "string",
  • "notes": "string",
  • "bindingExclusionNote": "string",
  • "totals": {
    },
  • "lineItems": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>
Request Body schema: application/json
required
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 null means "none — and do not apply the default". On a replace, omitting it stores nothing — that is how a title is cleared.

text
string or null

Body text. On a create, omitting it takes the company-wide default, while an explicit null means "none — and do not apply the default". On a replace, omitting it stores nothing.

notes
string or null

Note. On a create, omitting it takes the company-wide default, while an explicit null means "none — and do not apply the default". On a replace, omitting it stores nothing — deleting the note and saving clears it, and the company default does not come back.

bindingExclusionNote
string
required
Array of objects (CreateOfferLineItem) non-empty

Responses

Request samples

Content type
application/json
{
  • "currency": "string",
  • "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
  • "buyer": {
    },
  • "seller": {
    },
  • "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": [
    ]
}

Response samples

Content type
application/json
{
  • "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": {
    },
  • "buyer": {
    },
  • "buyerReference": "string",
  • "buyerElectronicAddress": "string",
  • "buyerEmail": "string",
  • "title": "string",
  • "text": "string",
  • "notes": "string",
  • "bindingExclusionNote": "string",
  • "totals": {
    },
  • "lineItems": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

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:
OidcBearerApiKeyBearer

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
Request Body schema: application/json
required
offerNumberPrefix
required
string
offerNumberPadding
required
integer [ 1 .. 12 ]

Responses

Request samples

Content type
application/json
{
  • "offerNumberPrefix": "AN-",
  • "offerNumberPadding": 5
}

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "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": {
    },
  • "buyer": {
    },
  • "buyerReference": "string",
  • "buyerElectronicAddress": "string",
  • "buyerEmail": "string",
  • "title": "string",
  • "text": "string",
  • "notes": "string",
  • "bindingExclusionNote": "string",
  • "totals": {
    },
  • "lineItems": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "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": {
    },
  • "buyer": {
    },
  • "buyerReference": "string",
  • "buyerElectronicAddress": "string",
  • "buyerEmail": "string",
  • "title": "string",
  • "text": "string",
  • "notes": "string",
  • "bindingExclusionNote": "string",
  • "totals": {
    },
  • "lineItems": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "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": {
    },
  • "buyer": {
    },
  • "buyerReference": "string",
  • "buyerElectronicAddress": "string",
  • "buyerEmail": "string",
  • "title": "string",
  • "text": "string",
  • "notes": "string",
  • "bindingExclusionNote": "string",
  • "totals": {
    },
  • "lineItems": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>
header Parameters
Idempotency-Key
string

Responses

Response samples

Content type
application/json
{
  • "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": {
    },
  • "transmission": {
    },
  • "capabilities": {
    },
  • "dueDate": "2019-08-24",
  • "paymentTerms": "string",
  • "invoiceTypeCode": "380",
  • "reversalScope": "full",
  • "precedingInvoice": {
    },
  • "correctedBy": [
    ],
  • "sourceOfferId": "27f452e8-4c83-478a-af96-74a338a701ab",
  • "sourceOffer": {
    },
  • "correctionDocuments": [
    ],
  • "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
  • "documentTarget": "b2b",
  • "seller": {
    },
  • "sellerIsKleinunternehmer": true,
  • "isDauerrechnung": true,
  • "dauerrechnungState": "active",
  • "buyer": {
    },
  • "buyerReference": "string",
  • "buyerEmail": "user@example.com",
  • "notes": "string",
  • "lineItems": [
    ],
  • "totals": {
    },
  • "taxBreakdown": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>
header Parameters
Idempotency-Key
string

Responses

Response samples

Content type
application/json
{
  • "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"
}

Kunden & Artikel

Manage customers and the reusable line-item catalogue (positions).

List customers (master data), ordered by name (paginated)

Authorizations:
OidcBearerApiKeyBearer
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

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "pagination": {
    }
}

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:
OidcBearerApiKeyBearer
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 (tenant, route, key); a REPLAY (same key + byte-identical body, after the first completed) returns the recorded response verbatim WITHOUT re-executing — so a network-blip retry never creates a duplicate customer/subscription/key. A key reused with a DIFFERENT body is 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY; a key whose first request is still in flight is 409 REQUEST_IN_PROGRESS (retryable). Keys expire after 24h — a key reused after expiry is a fresh request, not a replay. Transparent when omitted (the request runs normally).

Request Body schema: application/json
required
name
required
string
addressLine1
string
postalCode
string
city
string
countryCode
string

ISO 3166-1 alpha-2 (2 letters)

vatId
string
email
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 (0204 / EM) is derived at XRechnung generation time.

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

Request samples

Content type
application/json
{
  • "name": "string",
  • "addressLine1": "string",
  • "postalCode": "string",
  • "city": "string",
  • "countryCode": "string",
  • "vatId": "string",
  • "email": "user@example.com",
  • "defaultBuyerReference": "string",
  • "defaultElectronicAddress": "string",
  • "documentTarget": "b2b"
}

Response samples

Content type
application/json
{
  • "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": {
    },
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "mode": "live"
}

Get a customer by id

Authorizations:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "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": {
    },
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "mode": "live"
}

Update a customer (full replace of editable fields)

Authorizations:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>
Request Body schema: application/json
required
name
required
string
addressLine1
string
postalCode
string
city
string
countryCode
string

ISO 3166-1 alpha-2 (2 letters)

vatId
string
email
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 (0204 / EM) is derived at XRechnung generation time.

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

Request samples

Content type
application/json
{
  • "name": "string",
  • "addressLine1": "string",
  • "postalCode": "string",
  • "city": "string",
  • "countryCode": "string",
  • "vatId": "string",
  • "email": "user@example.com",
  • "defaultBuyerReference": "string",
  • "defaultElectronicAddress": "string",
  • "documentTarget": "b2b"
}

Response samples

Content type
application/json
{
  • "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": {
    },
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "mode": "live"
}

Delete a customer (linked invoices keep their snapshot)

Authorizations:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
  • "assignments": [
    ]
}

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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>
Request Body schema: application/json
required
required
Array of objects <= 500 items

Responses

Request samples

Content type
application/json
{
  • "assignments": [
    ]
}

Response samples

Content type
application/json
{
  • "assignments": [
    ]
}

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:
OidcBearerApiKeyBearer
query Parameters
page
integer >= 1
Default: 1

Page number (1-indexed).

pageSize
integer [ 1 .. 100 ]
Default: 25

Number of items per page.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "pagination": {
    }
}

Create a position

Honors the optional Idempotency-Key header so a retried creation is a replay, not a duplicate.

Authorizations:
OidcBearerApiKeyBearer
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 (tenant, route, key); a REPLAY (same key + byte-identical body, after the first completed) returns the recorded response verbatim WITHOUT re-executing — so a network-blip retry never creates a duplicate customer/subscription/key. A key reused with a DIFFERENT body is 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY; a key whose first request is still in flight is 409 REQUEST_IN_PROGRESS (retryable). Keys expire after 24h — a key reused after expiry is a fresh request, not a replay. Transparent when omitted (the request runs normally).

Request Body schema: application/json
required
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

Request samples

Content type
application/json
{
  • "description": "string",
  • "unitCode": "C62",
  • "unitPriceNet": "9542.50",
  • "taxRatePercent": "19",
  • "currency": "str",
  • "defaultQuantity": "9542.50"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "description": "string",
  • "unitCode": "C62",
  • "unitPriceNet": "9542.50",
  • "taxRatePercent": "9542.50",
  • "currency": "string",
  • "defaultQuantity": "9542.50",
  • "assignments": [
    ],
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "mode": "live"
}

Get a position by id (with its customer assignments)

Authorizations:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "description": "string",
  • "unitCode": "C62",
  • "unitPriceNet": "9542.50",
  • "taxRatePercent": "9542.50",
  • "currency": "string",
  • "defaultQuantity": "9542.50",
  • "assignments": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>
Request Body schema: application/json
required
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

Request samples

Content type
application/json
{
  • "description": "string",
  • "unitCode": "C62",
  • "unitPriceNet": "9542.50",
  • "taxRatePercent": "19",
  • "currency": "str",
  • "defaultQuantity": "9542.50"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "description": "string",
  • "unitCode": "C62",
  • "unitPriceNet": "9542.50",
  • "taxRatePercent": "9542.50",
  • "currency": "string",
  • "defaultQuantity": "9542.50",
  • "assignments": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

The customer whose open items are returned.

Responses

Response samples

Content type
application/json
{
  • "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
  • "dunningEnabled": true,
  • "items": [
    ],
  • "totals": {
    }
}

Abonnements

Recurring subscription templates, their settings, and generated invoices.

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:
OidcBearerApiKeyBearer

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
Request Body schema: application/json
required
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

Request samples

Content type
application/json
{
  • "defaultCadence": "weekly",
  • "defaultBillingTiming": "advance",
  • "defaultCurrency": "str",
  • "defaultTrialDays": 365
}

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
query Parameters
page
integer >= 1
Default: 1

Page number (1-indexed).

pageSize
integer [ 1 .. 100 ]
Default: 25

Number of items per page.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "pagination": {
    }
}

Create a subscription template

Honors the optional Idempotency-Key header so a retried creation is a replay, not a duplicate.

Authorizations:
OidcBearerApiKeyBearer
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 (tenant, route, key); a REPLAY (same key + byte-identical body, after the first completed) returns the recorded response verbatim WITHOUT re-executing — so a network-blip retry never creates a duplicate customer/subscription/key. A key reused with a DIFFERENT body is 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY; a key whose first request is still in flight is 409 REQUEST_IN_PROGRESS (retryable). Keys expire after 24h — a key reused after expiry is a fresh request, not a replay. Transparent when omitted (the request runs normally).

Request Body schema: application/json
required
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 (0204 for a Leitweg-ID, EM for an email) is derived server-side, so a 0204:-prefixed value is rejected as malformed.

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 renders below).

notes
string <= 2000 characters
required
Array of objects (SubscriptionLineItem) non-empty

Responses

Request samples

Content type
application/json
{
  • "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": [
    ]
}

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>
Request Body schema: application/json
required
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 (0204 for a Leitweg-ID, EM for an email) is derived server-side, so a 0204:-prefixed value is rejected as malformed.

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 renders below).

notes
string <= 2000 characters
required
Array of objects (SubscriptionLineItem) non-empty

Responses

Request samples

Content type
application/json
{
  • "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": [
    ]
}

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>
Request Body schema: application/json
required
status
required
string
Enum: "active" "paused" "cancelled" "ended"

Responses

Request samples

Content type
application/json
{
  • "status": "active"
}

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
[
  • {
    }
]

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:
OidcBearerApiKeyBearer
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 (tenant, route, key); a REPLAY (same key + byte-identical body, after the first completed) returns the recorded response verbatim WITHOUT re-executing — so a network-blip retry never creates a duplicate customer/subscription/key. A key reused with a DIFFERENT body is 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY; a key whose first request is still in flight is 409 REQUEST_IN_PROGRESS (retryable). Keys expire after 24h — a key reused after expiry is a fresh request, not a replay. Transparent when omitted (the request runs normally).

Responses

Response samples

Content type
application/json
{
  • "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": {
    },
  • "transmission": {
    },
  • "capabilities": {
    },
  • "dueDate": "2019-08-24",
  • "paymentTerms": "string",
  • "invoiceTypeCode": "380",
  • "reversalScope": "full",
  • "precedingInvoice": {
    },
  • "correctedBy": [
    ],
  • "sourceOfferId": "27f452e8-4c83-478a-af96-74a338a701ab",
  • "sourceOffer": {
    },
  • "correctionDocuments": [
    ],
  • "customerId": "87d8e330-2878-4742-a86f-dbbb3bf522ac",
  • "documentTarget": "b2b",
  • "seller": {
    },
  • "sellerIsKleinunternehmer": true,
  • "isDauerrechnung": true,
  • "dauerrechnungState": "active",
  • "buyer": {
    },
  • "buyerReference": "string",
  • "buyerEmail": "user@example.com",
  • "notes": "string",
  • "lineItems": [
    ],
  • "totals": {
    },
  • "taxBreakdown": [
    ],
  • "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"
}

Mahnwesen

Overdue tracking, dunning policy, and payment reminders (Mahnwesen).

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:
OidcBearerApiKeyBearer
query Parameters
cursor
string

Opaque keyset cursor from a prior page's nextCursor.

limit
integer [ 1 .. 100 ]
Default: 50

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "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:
OidcBearerApiKeyBearer

Responses

Response samples

Content type
application/json
{
  • "levels": [
    ],
  • "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:
OidcBearerApiKeyBearer
Request Body schema: application/json
required
required
Array of objects (DunningLevel) non-empty

Responses

Request samples

Content type
application/json
{
  • "levels": [
    ]
}

Response samples

Content type
application/json
{
  • "levels": [
    ],
  • "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:
OidcBearerApiKeyBearer
Request Body schema: application/json
required
requireSendEvidence
required
string
Enum: "any" "system_transmitted"

Responses

Request samples

Content type
application/json
{
  • "requireSendEvidence": "any"
}

Response samples

Content type
application/json
{
  • "levels": [
    ],
  • "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:
OidcBearerApiKeyBearer
Request Body schema: application/json
required
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 act/365), or null when none is configured.

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

Request samples

Content type
application/json
{
  • "interestFeatureEnabled": true,
  • "basiszinssatzBp": 0,
  • "interestSpreadB2bBp": 0,
  • "interestSpreadB2cBp": 0,
  • "interestDayCountBasis": "string",
  • "dunningFeeMinor": 0,
  • "verzugspauschaleB2bMinor": 0
}

Response samples

Content type
application/json
{
  • "levels": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

The invoice whose Mahnverlauf to read.

Responses

Response samples

Content type
application/json
{
  • "invoiceId": "4f163819-178d-470c-a246-d6768476a6ec",
  • "reminders": [
    ]
}

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:
OidcBearerApiKeyBearer
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 (tenant, route, key); a REPLAY (same key + byte-identical body, after the first completed) returns the recorded response verbatim WITHOUT re-executing — so a network-blip retry never creates a duplicate customer/subscription/key. A key reused with a DIFFERENT body is 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY; a key whose first request is still in flight is 409 REQUEST_IN_PROGRESS (retryable). Keys expire after 24h — a key reused after expiry is a fresh request, not a replay. Transparent when omitted (the request runs normally).

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

The invoice to preview a reminder for.

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

The invoice to preview the next-due Mahnung for.

Responses

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:
OidcBearerApiKeyBearer
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

Zahlungsabgleich

Inbound payment reconciliation: sources, received events, and matching.

List inbound sources for the active tenant/entity

Admin/owner only (requireTenantContext + requireRole('admin')). Secrets are never returned here — only the masked configPrefix.

Authorizations:
OidcBearerApiKeyBearer

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

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:
OidcBearerApiKeyBearer
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 (tenant, route, key); a REPLAY (same key + byte-identical body, after the first completed) returns the recorded response verbatim WITHOUT re-executing — so a network-blip retry never creates a duplicate customer/subscription/key. A key reused with a DIFFERENT body is 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY; a key whose first request is still in flight is 409 REQUEST_IN_PROGRESS (retryable). Keys expire after 24h — a key reused after expiry is a fresh request, not a replay. Transparent when omitted (the request runs normally).

Request Body schema: application/json
required
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 fetch_to_confirm; SSRF-checked at creation (internal host ⇒ 400).

Responses

Request samples

Content type
application/json
{
  • "type": "payment_provider",
  • "verificationMethod": "hmac",
  • "subscribedTypes": [
    ],
  • "mode": "live",
  • "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
  • "fetchBaseUrl": "string"
}

Response samples

Content type
application/json
{
  • "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": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Inbound source id.

Responses

Response samples

Content type
application/json
{
  • "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": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Inbound source id.

Request Body schema: application/json
required
non-empty
subscribedTypes
Array of strings non-empty
status
string
Enum: "active" "disabled"

Responses

Request samples

Content type
application/json
{
  • "subscribedTypes": [
    ],
  • "status": "active"
}

Response samples

Content type
application/json
{
  • "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": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Inbound source id.

Responses

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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Inbound source id.

Responses

Response samples

Content type
application/json
{
  • "config": {
    }
}

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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Inbound source id.

Responses

Response samples

Content type
application/json
{
  • "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": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Inbound source id.

Request Body schema: application/json
required
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

Request samples

Content type
application/json
{
  • "invoiceNumber": "string",
  • "amountMinor": 0,
  • "currency": "EUR",
  • "remittanceInfo": "string",
  • "structuredReference": "string",
  • "valueDate": "string",
  • "debtorIban": "string",
  • "dedupKey": "string",
  • "type": "payment_received"
}

Response samples

Content type
application/json
{
  • "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. (hmac sources).

Webhook-Signature
string

v1,<base64> — HMAC-SHA256 over {Webhook-Id}.{Webhook-Timestamp}.{raw body}. May carry several space-delimited signatures during a secret rotation; accept if ANY verifies. (hmac sources).

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). (hmac sources).

X-Inbound-Token
string

The shared token we assigned (shared_secret sources).

Request Body schema: application/json
required
object

Source-native event notification (metadata-only; never a document).

Responses

Request samples

Content type
application/json
{ }

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:
OidcBearerApiKeyBearer
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 rejected_* values rather than four. Members: processed, ignored_duplicate, ignored_already_paid, rejected_signature, rejected_no_match, rejected_stale, rejected_unparseable, pending_confirmation. An unknown member is a clean 400, never an empty page. ⛔ Deliberately type: string and NOT an enum — an enum here would document the comma form as invalid, which is the one form this parameter exists to accept.

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 nextCursor.

limit
integer [ 1 .. 100 ]
Default: 50

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

The inbound-event id.

Responses

Response samples

Content type
application/json
{
  • "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": {
    },
  • "timeline": [
    ]
}

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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

The inbound-event id to re-dispatch.

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

The inbound-event id (a pending_confirmation event the matching engine suggested).

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
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 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY; one still in flight is 409 REQUEST_IN_PROGRESS. Keys expire after 24h. A 4xx consumes the key — a corrected retry needs a fresh one.

Request Body schema: application/json
required
mode
required
string
Enum: "settle_remaining" "exact_amount"

The stated intent. settle_remaining allocates the whole open amount, computed server-side, and carries no money value at all. exact_amount allocates the amountMinor you state. This enum is exactly the set of modes the server will execute; a recognised-but-unpublished value returns 400 payment_mode_not_enabled.

amountMinor
integer >= 1

exact_amount only, and REQUIRED there. The amount that actually arrived, as a whole number of MINOR UNITS in the invoice's currency — 98000 is 980,00 EUR. A string, a fractional number or a decimal comma is 400 payment_amount_required; 0 or below is 422 payment_amount_out_of_range. Nothing is rounded or rescaled. Sending it with settle_remaining is 400 payment_intent_conflict.

allowOverpayment
boolean
Default: false

exact_amount only. Permission to book MORE than the invoice's open amount. Without it an excessive amount is 409 payment_exceeds_open_amount, which states the true remaining balance. An absent value is false — never permission. Sending it with settle_remaining is 400 payment_intent_conflict.

valueDate
string <date>

The calendar date the money arrived (YYYY-MM-DD). Defaults to the server's local date. Must be within [2000-01-01, today + 1 year] — outside that range is 422 validation_error.

Responses

Request samples

Content type
application/json
{
  • "mode": "exact_amount",
  • "amountMinor": 98000,
  • "valueDate": "2026-08-09"
}

Response samples

Content type
application/json
{
  • "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": {
    }
}

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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

The invoice whose payment ledger to read.

Responses

Response samples

Content type
application/json
{
  • "summary": {
    },
  • "entries": [
    ]
}

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:
OidcBearerApiKeyBearer
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 GET /invoices/{id}/payments.

header Parameters
Idempotency-Key
required
string <= 255 characters

REQUIRED on this route — same contract as POST /invoices/{id}/payments.

Request Body schema: application/json
required
required
object

Responses

Request samples

Content type
application/json
{
  • "reason": {
    }
}

Response samples

Content type
application/json
{
  • "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": {
    }
}

Archiv & Export

GoBD archive access and export jobs (DATEV, GoBD export packages).

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:
OidcBearerApiKeyBearer
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

Response samples

Content type
text/csv
charset=windows-1252; CRLF line endings

The 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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "documentId": "4704590c-004e-410d-adf7-acb7ca0a7052",
  • "objects": [
    ]
}

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:
OidcBearerApiKeyBearer
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 nextCursor. Reset it on a filter change.

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 (invoices.issue_date; RFC3339, reduced to its calendar day) — not archived_at.

to
string <date-time>

Inclusive upper bound on the issue date / Rechnungsdatum (invoices.issue_date; RFC3339, reduced to its calendar day) — not archived_at.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "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:
OidcBearerApiKeyBearer
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

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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>
Request Body schema: application/json
required
exportJobId
required
string <uuid>

The delivered pre_deletion_export job to approve.

Responses

Request samples

Content type
application/json
{
  • "exportJobId": "8a16feed-2e0d-4228-8fbb-a05bb4aba530"
}

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>
jobId
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
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 (tenant, route, key); a REPLAY (same key + byte-identical body, after the first completed) returns the recorded response verbatim WITHOUT re-executing — so a network-blip retry never creates a duplicate customer/subscription/key. A key reused with a DIFFERENT body is 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY; a key whose first request is still in flight is 409 REQUEST_IN_PROGRESS (retryable). Keys expire after 24h — a key reused after expiry is a fresh request, not a replay. Transparent when omitted (the request runs normally).

Request Body schema: application/json
optional
purpose
string
Default: "self_serve_export"
Enum: "self_serve_export" "pre_deletion_export"

self_serve_export (the customer wants their data) or pre_deletion_export (the export that gates a pending deletion).

Responses

Request samples

Content type
application/json
{
  • "purpose": "self_serve_export"
}

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>
Request Body schema: application/json
required
manifestSha256
required
string^[0-9a-f]{64}$

The sha256 the client computed over the downloaded manifest.json.

Responses

Request samples

Content type
application/json
{
  • "manifestSha256": "string"
}

Response samples

Content type
application/json
{
  • "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"
}

Einstellungen

Company profile, DATEV mapping, and email/SMTP configuration.

Get company profile (seller master data and invoice settings)

Authorizations:
OidcBearerApiKeyBearer

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "sellerName": "string",
  • "sellerAddressLine1": "string",
  • "sellerPostalCode": "string",
  • "sellerCity": "string",
  • "sellerCountryCode": "string",
  • "sellerVatId": "string",
  • "logoUrl": "http://example.com",
  • "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:
OidcBearerApiKeyBearer
Request Body schema: application/json
required
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

Request samples

Content type
application/json
{
  • "sellerName": "string",
  • "sellerAddressLine1": "string",
  • "sellerPostalCode": "string",
  • "sellerCity": "string",
  • "sellerCountryCode": "st",
  • "sellerVatId": "string",
  • "logoUrl": "http://example.com",
  • "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

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "sellerName": "string",
  • "sellerAddressLine1": "string",
  • "sellerPostalCode": "string",
  • "sellerCity": "string",
  • "sellerCountryCode": "string",
  • "sellerVatId": "string",
  • "logoUrl": "http://example.com",
  • "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:
OidcBearerApiKeyBearer
Request Body schema: application/json
required
isKleinunternehmer
required
boolean

Whether the seller is a Kleinunternehmer (§19 UStG).

Responses

Request samples

Content type
application/json
{
  • "isKleinunternehmer": true
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "sellerName": "string",
  • "sellerAddressLine1": "string",
  • "sellerPostalCode": "string",
  • "sellerCity": "string",
  • "sellerCountryCode": "string",
  • "sellerVatId": "string",
  • "logoUrl": "http://example.com",
  • "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:
OidcBearerApiKeyBearer
query Parameters
version
integer >= 0

A specific historical version to fetch. 0 = built-in SKR default.

Responses

Response samples

Content type
application/json
{
  • "skr": "03",
  • "sachkontenlaenge": 4,
  • "beraternummer": "",
  • "mandantennummer": "",
  • "wjStartMonth": 1,
  • "festschreibung": 0,
  • "debitorStrategy": "collective",
  • "collectiveDebitorKonto": 0,
  • "debitorAccounts": {
    },
  • "revenueAccounts": [
    ],
  • "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:
OidcBearerApiKeyBearer
Request Body schema: application/json
required
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

Request samples

Content type
application/json
{
  • "skr": "03",
  • "sachkontenlaenge": 4,
  • "beraternummer": "",
  • "mandantennummer": "",
  • "wjStartMonth": 1,
  • "festschreibung": 0,
  • "debitorStrategy": "collective",
  • "collectiveDebitorKonto": 0,
  • "debitorAccounts": {
    },
  • "revenueAccounts": [
    ]
}

Response samples

Content type
application/json
{
  • "skr": "03",
  • "sachkontenlaenge": 4,
  • "beraternummer": "",
  • "mandantennummer": "",
  • "wjStartMonth": 1,
  • "festschreibung": 0,
  • "debitorStrategy": "collective",
  • "collectiveDebitorKonto": 0,
  • "debitorAccounts": {
    },
  • "revenueAccounts": [
    ],
  • "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:
OidcBearerApiKeyBearer
Request Body schema: application/json
required
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

Request samples

Content type
application/json
{
  • "invoicePrefix": "string",
  • "invoiceNumberPadding": 1
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "sellerName": "string",
  • "sellerAddressLine1": "string",
  • "sellerPostalCode": "string",
  • "sellerCity": "string",
  • "sellerCountryCode": "string",
  • "sellerVatId": "string",
  • "logoUrl": "http://example.com",
  • "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:
OidcBearerApiKeyBearer
Request Body schema: application/json
required
invoiceNumberCounter
required
integer >= 0

Responses

Request samples

Content type
application/json
{
  • "invoiceNumberCounter": 0
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "sellerName": "string",
  • "sellerAddressLine1": "string",
  • "sellerPostalCode": "string",
  • "sellerCity": "string",
  • "sellerCountryCode": "string",
  • "sellerVatId": "string",
  • "logoUrl": "http://example.com",
  • "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:
OidcBearerApiKeyBearer

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
Request Body schema: application/json
required
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 password_not_on_replace) and nothing is written; replace the stored password with PUT /settings/email/password. It is never read back by any endpoint, which is exactly why it is not a field a replace can restate.

fromName
string <= 200 characters

Optional display name in the From header of every message sent for this entity. Omitting it CLEARS the stored value, and the next message then goes out with the bare address as its sender. Send the current value to keep it.

fromEmail
required
string <email> <= 254 characters
replyTo
string or null <email>

Optional Reply-To header for every message sent for this entity. Omitting it CLEARS the stored value, and customer replies then go to the sending address instead of the mailbox chosen here. Send the current value to keep it.

subjectTemplate
required
string [ 1 .. 998 ] characters
bodyTemplateHtml
required
string [ 1 .. 50000 ] characters

Responses

Request samples

Content type
application/json
{
  • "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

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
Request Body schema: application/json
required
password
required
string [ 1 .. 1024 ] characters

The new SMTP password. Write-only; it is never read back.

Responses

Request samples

Content type
application/json
{
  • "password": "string"
}

Response samples

Content type
application/json
{
  • "error": "validation_error",
  • "issues": [
    ]
}

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:
OidcBearerApiKeyBearer
Request Body schema: application/json
required
to
required
string <email> <= 254 characters

Responses

Request samples

Content type
application/json
{
  • "to": "user@example.com"
}

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer

Responses

Response samples

Content type
application/json
{
  • "verified": {
    },
  • "pending": {
    }
}

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:
OidcBearerApiKeyBearer
Request Body schema: application/json
required
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

Request samples

Content type
application/json
{
  • "address": "buchhaltung@mueller.example"
}

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
Request Body schema: application/json
required
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

Request samples

Content type
application/json
{
  • "code": "204815"
}

Response samples

Content type
application/json
{
  • "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/json
required
token
required
string [ 16 .. 512 ] characters

The single-use token from the confirmation link.

Responses

Request samples

Content type
application/json
{
  • "token": "stringstringstri"
}

Response samples

Content type
application/json
{
  • "address": "buchhaltung@mueller.example",
  • "verifiedAt": "2019-08-24T14:15:22Z"
}

Team & Zugriff

Tenants, entities, memberships, invitations, and access review.

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:
OidcBearerApiKeyBearer

Responses

Response samples

Content type
application/json
{
  • "memberships": [
    ]
}

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:
OidcBearerApiKeyBearer
Request Body schema: application/json
required
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 GET /legal/current-versions immediately before submitting. OPTIONAL in this schema and NOT optional in the act: the requirement is created by a document being published, not by this field. While nothing is published the field is meaningless and omitting it is ordinary. Once something IS published, omitting it — or naming only some of what is published — is refused 409 legal_acceptance_required, which names what is still outstanding. The version is a confirmation token, never the value stored: the server records the version it reads as current, so a version that is not the current one is refused rather than written. Each document may appear at most once. Who accepted and when are taken from the verified token and the transaction; there is deliberately no field for either, nor for the counterparty block, which the server composes.

Responses

Request samples

Content type
application/json
{
  • "name": "Muster GmbH",
  • "legalAcceptances": [
    ]
}

Response samples

Content type
application/json
{
  • "tenant": {
    },
  • "membership": {
    },
  • "entity": {
    }
}

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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

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 (tenant, route, key); a REPLAY (same key + byte-identical body, after the first completed) returns the recorded response verbatim WITHOUT re-executing — so a network-blip retry never creates a duplicate customer/subscription/key. A key reused with a DIFFERENT body is 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY; a key whose first request is still in flight is 409 REQUEST_IN_PROGRESS (retryable). Keys expire after 24h — a key reused after expiry is a fresh request, not a replay. Transparent when omitted (the request runs normally).

Request Body schema: application/json
required
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

Request samples

Content type
application/json
{
  • "name": "string",
  • "vatId": "string",
  • "addressLine1": "string",
  • "postalCode": "string",
  • "city": "string",
  • "countryCode": "st"
}

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

id
required
string <uuid>

Entity id.

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

id
required
string <uuid>

Entity id.

Request Body schema: application/json
required
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

Request samples

Content type
application/json
{
  • "name": "string",
  • "vatId": "string",
  • "addressLine1": "string",
  • "postalCode": "string",
  • "city": "string",
  • "countryCode": "st"
}

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

id
required
string <uuid>

Entity id.

Responses

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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

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 (tenant, route, key); a REPLAY (same key + byte-identical body, after the first completed) returns the recorded response verbatim WITHOUT re-executing — so a network-blip retry never creates a duplicate customer/subscription/key. A key reused with a DIFFERENT body is 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY; a key whose first request is still in flight is 409 REQUEST_IN_PROGRESS (retryable). Keys expire after 24h — a key reused after expiry is a fresh request, not a replay. Transparent when omitted (the request runs normally).

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

userId
required
string <uuid>

The member's users.id (the membership is keyed by (tenant, user)).

Request Body schema: application/json
required
role
required
string (MembershipRole)
Enum: "owner" "admin" "member" "readonly"

Total order (owner > admin > member > readonly).

Responses

Request samples

Content type
application/json
{
  • "role": "owner"
}

Response samples

Content type
application/json
{
  • "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": {
    }
}

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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

userId
required
string <uuid>

The member's users.id (the membership is keyed by (tenant, user)).

Responses

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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

Responses

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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

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 (tenant, route, key); a REPLAY (same key + byte-identical body, after the first completed) returns the recorded response verbatim WITHOUT re-executing — so a network-blip retry never creates a duplicate customer/subscription/key. A key reused with a DIFFERENT body is 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY; a key whose first request is still in flight is 409 REQUEST_IN_PROGRESS (retryable). Keys expire after 24h — a key reused after expiry is a fresh request, not a replay. Transparent when omitted (the request runs normally).

Request Body schema: application/json
required
email
required
string <email> <= 320 characters

Trimmed + lower-cased server-side.

role
required
string (MembershipRole)
Enum: "owner" "admin" "member" "readonly"

Total order (owner > admin > member > readonly).

Responses

Request samples

Content type
application/json
{
  • "email": "user@example.com",
  • "role": "owner"
}

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

id
required
string <uuid>

Invitation id.

Responses

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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

id
required
string <uuid>

Invitation id.

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
Request Body schema: application/json
required
token
required
string [ 1 .. 512 ] characters

The show-once invitation token from the emailed link.

Responses

Request samples

Content type
application/json
{
  • "token": "string"
}

Response samples

Content type
application/json
{
  • "outcome": "accepted",
  • "tenant": {
    },
  • "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

Response samples

Content type
application/json
{
  • "valid": true,
  • "tenantName": "string",
  • "authHint": "password",
  • "role": "owner",
  • "logoUrl": "string",
  • "primaryColor": "string"
}

Konto & Abrechnung

Plans, billing, usage, audit log, and account closure.

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:
OidcBearerApiKeyBearer

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer

Responses

Response samples

Content type
application/json
{
  • "blockers": [
    ]
}

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:
OidcBearerApiKeyBearer

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer

Responses

Response samples

Content type
application/json
{
  • "documents": [
    ]
}

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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

query Parameters
cursor
string

Opaque keyset cursor from a prior page's nextCursor. Reset it on a filter change.

limit
integer [ 1 .. 100 ]
Default: 25

Page size for cursor-paginated reads.

eventType
string

Filter by action, e.g. entity.created, api_key.revoked, user.login. Open string (NOT an enum) — the log carries event types from many subsystems (api_key.*, webhook.*, and auth/tenancy events).

actorId
string <uuid>

Filter by actor (users.id) — the Team "who did this" lens.

resourceType
string

Filter by target kind, e.g. entity, invitation, api_key.

from
string <date-time>

Inclusive timestamp lower bound (RFC3339).

to
string <date-time>

Inclusive timestamp upper bound (RFC3339).

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

query Parameters
from
string <date-time>

Inclusive lower bound of the reporting window (RFC3339, half-open [from, to)). Must be supplied together with to. Omit both to get the default: the current calendar month (UTC).

to
string <date-time>

Exclusive upper bound (RFC3339). Must be supplied together with from.

Responses

Response samples

Content type
application/json
{
  • "period": {
    },
  • "totalBillableUnits": 0,
  • "byEventType": {
    }
}

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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

Responses

Response samples

Content type
application/json
{
  • "tier": "launch",
  • "plan": {
    },
  • "account": {
    },
  • "usage": {
    },
  • "entitlement": {
    },
  • "cancellation": {
    },
  • "paymentMethodChange": {
    }
}

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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

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 (tenant, route, key); a REPLAY (same key + byte-identical body, after the first completed) returns the recorded response verbatim WITHOUT re-executing — so a network-blip retry never creates a duplicate customer/subscription/key. A key reused with a DIFFERENT body is 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY; a key whose first request is still in flight is 409 REQUEST_IN_PROGRESS (retryable). Keys expire after 24h — a key reused after expiry is a fresh request, not a replay. Transparent when omitted (the request runs normally).

Request Body schema: application/json
optional
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

Request samples

Content type
application/json
{
  • "method": "card",
  • "billingParty": {
    }
}

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

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 (tenant, route, key); a REPLAY (same key + byte-identical body, after the first completed) returns the recorded response verbatim WITHOUT re-executing — so a network-blip retry never creates a duplicate customer/subscription/key. A key reused with a DIFFERENT body is 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY; a key whose first request is still in flight is 409 REQUEST_IN_PROGRESS (retryable). Keys expire after 24h — a key reused after expiry is a fresh request, not a replay. Transparent when omitted (the request runs normally).

Responses

Response samples

Content type
application/json
{
  • "checkoutUrl": "http://example.com",
  • "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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

Responses

Response samples

Content type
application/json
{
  • "tier": "launch",
  • "plan": {
    },
  • "account": {
    },
  • "usage": {
    },
  • "entitlement": {
    },
  • "cancellation": {
    },
  • "paymentMethodChange": {
    }
}

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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

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 (tenant, route, key); a REPLAY (same key + byte-identical body, after the first completed) returns the recorded response verbatim WITHOUT re-executing — so a network-blip retry never creates a duplicate customer/subscription/key. A key reused with a DIFFERENT body is 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY; a key whose first request is still in flight is 409 REQUEST_IN_PROGRESS (retryable). Keys expire after 24h — a key reused after expiry is a fresh request, not a replay. Transparent when omitted (the request runs normally).

Request Body schema: application/json
optional
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

Request samples

Content type
application/json
{
  • "method": "card",
  • "billingParty": {
    }
}

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

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 (tenant, route, key); a REPLAY (same key + byte-identical body, after the first completed) returns the recorded response verbatim WITHOUT re-executing — so a network-blip retry never creates a duplicate customer/subscription/key. A key reused with a DIFFERENT body is 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY; a key whose first request is still in flight is 409 REQUEST_IN_PROGRESS (retryable). Keys expire after 24h — a key reused after expiry is a fresh request, not a replay. Transparent when omitted (the request runs normally).

Responses

Response samples

Content type
application/json
{
  • "checkoutUrl": "http://example.com",
  • "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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

Request Body schema: application/json
required
required
Array of objects non-empty

Each document at most once. The version must be the one currently published for that document.

Responses

Request samples

Content type
application/json
{
  • "documents": [
    ]
}

Response samples

Content type
application/json
{
  • "documents": [
    ]
}

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:
OidcBearerApiKeyBearer
path Parameters
tenantId
required
string <uuid>

Must be the caller's active tenant.

document
required
string

Which document, e.g. avv or agb.

Responses

Response samples

Content type
application/json
{
  • "error": "validation_error",
  • "issues": [
    ]
}

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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

Responses

Response samples

Content type
application/json
{
  • "documents": [
    ]
}

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

Response samples

Content type
application/json
{
  • "catalogVersion": "v1",
  • "plans": [
    ]
}

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

v1,<base64> — HMAC-SHA256 over {Webhook-Id}.{Webhook-Timestamp}.{raw body}. May carry several space-delimited signatures during a secret rotation; accept if ANY verifies.

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/json
required
object

Provider-native event body (metadata-only).

Responses

Request samples

Content type
application/json
{ }

API-Schlüssel

Issue, rotate, suspend, and revoke programmatic API keys.

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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

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 (tenant, route, key); a REPLAY (same key + byte-identical body, after the first completed) returns the recorded response verbatim WITHOUT re-executing — so a network-blip retry never creates a duplicate customer/subscription/key. A key reused with a DIFFERENT body is 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY; a key whose first request is still in flight is 409 REQUEST_IN_PROGRESS (retryable). Keys expire after 24h — a key reused after expiry is a fresh request, not a replay. Transparent when omitted (the request runs normally).

Request Body schema: application/json
required
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

Request samples

Content type
application/json
{
  • "name": "string",
  • "mode": "live",
  • "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
  • "scopes": [
    ],
  • "expiresAt": "2019-08-24T14:15:22Z"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "string",
  • "mode": "live",
  • "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
  • "displayPrefix": "sk_live_a1b2c3d4",
  • "scopes": [
    ],
  • "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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

id
required
string <uuid>

API key id.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "string",
  • "mode": "live",
  • "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
  • "displayPrefix": "sk_live_a1b2c3d4",
  • "scopes": [
    ],
  • "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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

id
required
string <uuid>

API key id.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "string",
  • "mode": "live",
  • "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
  • "displayPrefix": "sk_live_a1b2c3d4",
  • "scopes": [
    ],
  • "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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

id
required
string <uuid>

API key id.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "string",
  • "mode": "live",
  • "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
  • "displayPrefix": "sk_live_a1b2c3d4",
  • "scopes": [
    ],
  • "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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

id
required
string <uuid>

API key id.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "string",
  • "mode": "live",
  • "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
  • "displayPrefix": "sk_live_a1b2c3d4",
  • "scopes": [
    ],
  • "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:
OidcBearerApiKeyBearer
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 tenant_mismatch.

id
required
string <uuid>

API key id.

Responses

Webhooks

Register webhook endpoints and inspect/replay lifecycle deliveries.

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:
OidcBearerApiKeyBearer

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

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:
OidcBearerApiKeyBearer
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 (tenant, route, key); a REPLAY (same key + byte-identical body, after the first completed) returns the recorded response verbatim WITHOUT re-executing — so a network-blip retry never creates a duplicate customer/subscription/key. A key reused with a DIFFERENT body is 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY; a key whose first request is still in flight is 409 REQUEST_IN_PROGRESS (retryable). Keys expire after 24h — a key reused after expiry is a fresh request, not a replay. Transparent when omitted (the request runs normally).

Request Body schema: application/json
required
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

Request samples

Content type
application/json
{}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
  • "mode": "live",
  • "url": "string",
  • "subscribedTypes": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Webhook endpoint id.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
  • "mode": "live",
  • "url": "string",
  • "subscribedTypes": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Webhook endpoint id.

Request Body schema: application/json
required
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

Request samples

Content type
application/json
{
  • "url": "string",
  • "subscribedTypes": [
    ],
  • "status": "active"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
  • "mode": "live",
  • "url": "string",
  • "subscribedTypes": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Webhook endpoint id.

Responses

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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Webhook endpoint id.

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Webhook endpoint id.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "entityId": "156e622c-6cdf-4c27-9bc9-2f2db69919f5",
  • "mode": "live",
  • "url": "string",
  • "subscribedTypes": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Webhook endpoint id.

Request Body schema: application/json
required
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 event-types.ts: transmission_accepted→"sent", transmission_rejected→"rejected". The Zahlungsabgleich two-sided-emit codes are additive: invoice_paid→"invoice.paid" (an issued invoice became fully paid), payment_partial→"payment.partial" (a partial payment was allocated), payment_reconciled→"payment.reconciled" (a payment reconciled an invoice without a clean full flip, e.g. an overpayment). The retraction code is additive too: payment_reversed→"payment.reversed" (a recorded allocation was reversed by a compensating entry; if that takes the invoice back below fully paid it also un-pays it).

The payment 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 — because a receivable can be settled, retracted and settled again. Apply them in created_at order — not arrival order — and treat the latest created_at as current. Ordering by arrival rebuilds the very bug this contract avoids: delivery is at-least-once on a 1m→24h retry ladder, so a retried invoice.paid can reach you after a payment.reversed. 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.

The document-lifecycle codes (issued, archived, transmission_accepted, transmission_rejected) are unchanged: those transitions happen once per document by nature and are still emitted at most once.

Responses

Request samples

Content type
application/json
{
  • "eventCode": "issued"
}

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Webhook endpoint id.

query Parameters
cursor
string

Opaque keyset cursor from a prior page's nextCursor. Reset it on a filter change.

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. pending,delivering,retrying) filters by status IN (...) — the dashboard's "Wird wiederholt" bucket sends the three non-terminal statuses this way. An unknown status in the set → 400 invalid_status.

from
string <date-time>

Inclusive created_at lower bound (RFC3339).

to
string <date-time>

Inclusive created_at upper bound (RFC3339).

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "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:
OidcBearerApiKeyBearer
query Parameters
cursor
string

Opaque keyset cursor from a prior page's nextCursor. Reset it on a filter change.

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. pending,delivering,retrying) filters by status IN (...) — the dashboard's "Wird wiederholt" bucket sends the three non-terminal statuses this way. An unknown status in the set → 400 invalid_status.

from
string <date-time>

Inclusive created_at lower bound (RFC3339).

to
string <date-time>

Inclusive created_at upper bound (RFC3339).

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Delivery id.

Responses

Response samples

Content type
application/json
{
  • "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": [
    ]
}

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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

The original delivery id to replay.

Responses

Response samples

Content type
application/json
{
  • "deliveryId": "73dc828d-801d-4d29-b7e4-e046662a5901",
  • "eventId": "d6703cc8-9e79-415d-ac03-a4dc7f6ab43c",
  • "synthetic": true,
  • "replayOf": "f2f6a5b2-100f-405e-a1f2-e1612a376e2a"
}

Sandbox

Test-mode sandbox: seed, reset, and advance the test clock.

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:
OidcBearerApiKeyBearer

Responses

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
Request Body schema: application/json
required
toDate
required
string <date>

Responses

Request samples

Content type
application/json
{
  • "toDate": "2019-08-24"
}

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
Request Body schema: application/json
optional
reseed
boolean
Default: false

Responses

Request samples

Content type
application/json
{
  • "reseed": false
}

Response samples

Content type
application/json
{
  • "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:
OidcBearerApiKeyBearer
path Parameters
id
required
string <uuid>

Responses

Response samples

Content type
application/json
{
  • "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"
}