Following system theme

#Scribo HTTP API

The /api/v1/* namespace is the public Scribo contract — anonymous and rate-limited. The OpenAPI 3.1 spec is canonical and lives at /api/v1/openapi.json.

Base URL (production): https://scribo.causaprima.ai
Base URL (local dev):  http://localhost:3200

The internal /internal/scribo/* namespace is used only by the Causa Prima web app and the MCP server. It is not part of this contract — do not depend on it.

#Authentication

The /api/v1/* API is anonymous by default. Optional bearer tokens unlock elevated quotas for partners:

Authorization: Bearer <api-key>

Cloudflare Turnstile is enforced only on the Scribo web chat before its first LLM call. Public API callers do not send a CAPTCHA token.

#Email verification

The first invoice for each sender email must prove address ownership (one-time per sender). A tokenless POST /api/v1/invoices returns 401 email_verification_required. Flow:

  1. POST /api/v1/scribo/email-verifications with { "email": "<sender.contact_email>" } — Scribo emails a 6-character code. Always returns a uniform 202 with { challenge_id, poll_secret, expires_at, next_request_allowed_at } (anti-enumeration). The code expires in 15 minutes; sends are rate-limited (1 per 30 s, 5 per hour per email).
  2. POST /api/v1/scribo/email-verifications/{challenge_id}/redeem with { "code": "..." } — returns { verification_token, expires_at }. A wrong code returns 400 verification_invalid, and the fifth wrong code revokes the challenge. If the request carries the step 1 poll_secret in the X-Scribo-Verification-Secret header, that fifth wrong code and any redeem of a revoked, expired, or already-redeemed challenge return 410 verification_revoked: request a new code. Without it, such a challenge returns the same 400 verification_invalid.
  3. Retry POST /api/v1/invoices with the X-Email-Verification-Token header. One token covers ~30 minutes of invoices for the same sender. A token whose email doesn't match sender.contact_email returns 403 verification_email_mismatch.

GET …/{id} (snapshot) and GET …/{id}/events (SSE) support browser flows that wait for the user to confirm the emailed magic link, which redeems only in the browser that requested the challenge. A code redeem never hands them its token, which goes to the redeem caller alone: they report expired, and an open stream ends. Both require the poll_secret from step 1 in the X-Scribo-Verification-Secret header (read the stream with fetch, not EventSource, and never put the secret in a URL). Without it, or for an unknown id, they return 404 verification_not_found. Only the request that created the challenge gets a secret they accept; a repeat request for the same address while that challenge is pending gets a decoy, so finish by code.

#Endpoints

Method Path Description
POST /api/v1/invoices Generate a compliant e-invoice.
GET /api/v1/invoices/{id} Fetch invoice metadata and the download URL.
GET /api/v1/invoices/{id}/download Stream the invoice file bytes.
GET /api/v1/jurisdictions List supported jurisdictions and formats.
GET /api/v1/openapi.json Canonical OpenAPI 3.1 document.
POST /api/v1/scribo/email-verifications Request a sender email-ownership challenge.
POST /api/v1/scribo/email-verifications/{id}/redeem Exchange the 6-character code for a verification_token.
GET /api/v1/scribo/email-verifications/{id} Challenge status snapshot (polling fallback).
GET /api/v1/scribo/email-verifications/{id}/events SSE stream of challenge events.

Note: the download_url returned by POST /api/v1/invoices is durable. To re-download, use the download_url from the create response as-is, or fetch it again with GET /api/v1/invoices/{id}; don't construct your own. Today that URL points at /i/{id}/download on the scribo domain (a thin proxy in front of /api/v1/invoices/{id}/download on the core API), but the path layout is an internal detail that may change.

#POST /api/v1/invoices — generate an invoice

Anonymous endpoint. Rate-limited per source IP.

Headers

Header Required Notes
Content-Type: application/json yes —
Idempotency-Key recommended Same key + same payload returns the original invoice.
Authorization: Bearer <key> no Partner key for elevated quotas.
X-Email-Verification-Token first invoice per sender Token from the email verification redeem step. Omitted for an unverified sender → 401 email_verification_required.
X-Scribo-Locale no BCP-47 UI-language tag (e.g. de-DE, en-US). Sets the language of Scribo's outbound emails — both this endpoint's "invoice ready" notification and the email-verification message (whose confirmation page renders in the same language). Send the locale of the human you're acting for. Omitted or unsupported → English. UI-language only; it does not affect invoice content, currency, or jurisdiction.

Request body — CreateInvoiceInput

Required fields:

  • sender — { legal_name, country_code, address_line1, postcode, city, contact_email }; optional tax_id, contact_phone, contact_name, address_line2. country_code is checked against the ISO 3166-1 alpha-2 list — reserved / unassigned codes (XX, ZZ, QQ) are rejected.
  • recipient — same shape as sender; contact_email is required (the accounts-payable address Scribo's transparency / opt-out channel uses); optional tax_id, leitweg_id (present auto-selects XRechnung UBL).
  • line_items[] — { description, quantity, unit_price, tax_rate, tax_category_code }. Optional unit_code (UN/ECE Rec 20, default EA), discount ({ type: 'percent' | 'amount', value, reason } — reason is mandatory per EN 16931 BR-41), tax_exemption_code (VATEX-EU-*, required for tax_category_code = E), and tax_exemption_reason (free-form BT-120 note for the buyer). 1 to 500 items. tax_rate is bounded [0, 100]; quantity bounded at 999_999.999. Categories Z / E / AE / K / G / O must have tax_rate = "0".
  • currency — ISO 4217 (e.g. EUR, USD).

Optional:

  • jurisdiction — ISO 3166-1 alpha-2 override. Phase 1: only DE or US resolve successfully; everything else returns unsupported_jurisdiction.
  • format_override — one of zugferd_comfort, zugferd_basic, xrechnung_cii, xrechnung_ubl, plain_pdf. Phase 2 formats (factur_x, facturae, peppol_bis_ubl) are not yet accepted at the public boundary.
  • payment_means — { type: "credit_transfer", … } carrying either a SEPA iban or US domestic details (account_number 4–17 digits + 9-digit ABA routing_number) — not both. Optional on either path: bic (SWIFT), account_name (holder), bank (free-text beneficiary bank name + address). Mandatory with an IBAN when the resolved format is XRechnung (Leitweg-ID present, or format_override ∈ {xrechnung_cii, xrechnung_ubl}) — BR-DE-1; a US account cannot satisfy German B2G. Optional on every other format; ZUGFeRD embeds the IBAN, US plain PDFs render the payment details when supplied. An object carrying no account details at all ({ "type": "credit_transfer" }, or one whose every other field is blank) is treated as absent rather than rejected — it says nothing. That leniency only helps where payment_means is optional: on an XRechnung invoice the resulting absence still fails BR-DE-1, so { "type": "credit_transfer" } does not make a German B2G invoice issuable — a valid iban is required. Half-filled details (an account number without a routing number, a lone BIC, a malformed IBAN) are still rejected, as is a mis-cased or unrecognized key carrying a real value (e.g. IBAN, sort_code) — those are treated as supplied-but-wrong, not absent.
  • notes (≤ 1000 chars), due_date, payment_terms, delivery_date, delivery_period.

Most optional string fields accept an empty string (or null) as "absent" — "" reads the same as omitting the key, so a caller (typically an LLM) that pads fields it has no value for is not rejected for it. A non-empty value is always validated in full.

This blank tolerance is a server-side leniency for LLM callers, not part of the published request schema: docs/openapi/scribo-v1.json describes the post-normalization shape, so its minLength / pattern constraints still reject "". A client generated from that spec may therefore refuse a blank locally even though the API would accept it. Integrators writing code should omit a field they have no value for rather than send a blank.

Four fields deliberately do not coerce a blank, because absent does not mean "nothing" for them — it substitutes a value: issue_date (defaults to today), due_date ("Due upon receipt"), delivery_date (defaults to the issue date), and format_override (resolves the format from the jurisdiction). A blank on any of those is rejected rather than silently changing what the invoice says. Object-typed fields (payment_means, delivery_period) are not string-coercible either: send the object or omit the key.

Numeric fields (quantity, unit_price, tax_rate) are strings holding decimal numbers — this preserves precision through JSON. The API rejects floats.

Example — already-verified sender. On the first invoice for a sender email, also send X-Email-Verification-Token (see Email verification) or the call returns 401 email_verification_required.

POST /api/v1/invoices HTTP/1.1
Host: scribo.causaprima.ai
Content-Type: application/json
Idempotency-Key: 0a3f2b4c-...
{
  "sender": {
    "legal_name": "Example GmbH",
    "country_code": "DE",
    "address_line1": "Example Allee 1",
    "postcode": "10115",
    "city": "Berlin",
    "tax_id": "DE123456788",
    "contact_email": "billing@example.com"
  },
  "recipient": {
    "legal_name": "Acme GmbH",
    "country_code": "DE",
    "address_line1": "Hauptstrasse 1",
    "postcode": "10117",
    "city": "Berlin",
    "tax_id": "DE987654321",
    "contact_email": "ap@acme.example"
  },
  "line_items": [
    {
      "description": "Senior consulting, 3 days",
      "quantity": "3",
      "unit_code": "DAY",
      "unit_price": "1200.00",
      "tax_rate": "19",
      "tax_category_code": "S"
    }
  ],
  "currency": "EUR",
  "due_date": "2026-06-01"
}

Response 201 — InvoiceRecord

{
  "invoice_id": "f0b3c1e2-...",
  "document_id": "9a7e1d2c-...",
  "format": "zugferd_comfort",
  "download_url": "https://scribo.causaprima.ai/i/f0b3c1e2-.../download",
  "download_url_expires_at": "2026-05-18T14:15:00Z",
  "validator_summary": { "valid": true, "validator": "invopop", "errors": [] },
  "magic_link_sent": true
}

The download_url is durable — links remain accessible and you can re-download from any device at any time. (download_url_expires_at currently emits a timestamp 15 minutes after the response, but nothing enforces it and the link keeps working; do not key business logic off it.) The magic_link_sent flag indicates whether Scribo emailed the invoice notification (summary + download link) to sender.contact_email — true when the email was queued, false if it was throttled or the send failed. (Email ownership is proven earlier in the flow via the verification challenge, not by this notification.)

XRechnung outputs additionally return preview_url + preview_url_expires_at (URL to a human-readable PDF rendering of the XML; the XML at download_url is the legal artifact) and a submission object (status, message, manual_upload_hints for the federal procurement portals; automated B2G submission is not implemented in Phase 1). Both are absent on ZUGFeRD and plain PDF.

Response codes

Status Meaning See
201 Invoice generated and persisted. —
400 Invalid input or downstream validator rejection. validator_failed, unsupported_jurisdiction
401 Sender email not verified yet. email_verification_required
403 Verified email ≠ sender email, or tenant soft-blocked. verification_email_mismatch, tenant_soft_blocked
409 The invoice number is already used for this business partner with different facts, the recipient matches more than one business partner, or its tax ID conflicts with the one on record. details.reason says which. Nothing was recorded. invoice_source_review_required
422 Idempotency-Key mismatch. idempotency_key_mismatch
429 Rate limit or tenant quota exceeded. rate_limited, tenant_invoice_quota_exceeded
503 Another invoice for the same sender is being recorded, or storing the invoice timed out. Nothing was recorded; retry after Retry-After (seconds). invoice_admission_busy, invoice_storage_unavailable

#GET /api/v1/invoices/{id}/download — download bytes

Streams the generated file: the XML legal artifact for XRechnung, the hybrid PDF/A-3 with embedded XML for ZUGFeRD, or the plain PDF. For XRechnung, ?variant=preview streams the PDF visualisation. Future revisions may 302 to a signed object-storage URL. No session is checked: the invoice ID in the path is the access credential. An unknown ID returns 404 artifact_not_found.

#GET /api/v1/invoices/{id} — fetch metadata

Returns the same InvoiceRecord shape as POST /api/v1/invoices, with the download URLs rebuilt and magic_link_sent always false. No session is checked: the invoice ID is the access credential, so anyone holding it can read the metadata and download the file. Treat invoice IDs and download URLs as secrets. An unknown ID returns 404 invoice_not_found.

#GET /api/v1/jurisdictions — list supported jurisdictions

Returns an array of { jurisdiction, formats[], default_format }. Useful to confirm support before collecting recipient details.

#Error envelope

Every non-2xx response shares the same shape:

{
  "error": {
    "code": "rate_limited",
    "message": "Rate limit exceeded",
    "details": { "...": "..." }
  }
}

See troubleshooting for every error code Scribo emits, when it fires, and how to recover.

#Rate Limits

  • 600 requests per minute per source IP — cheap flood gate before validation.
  • 200 invoice-generation requests per hour per source IP.
  • 5 invoice-generation requests per minute per sender tenant.
  • 50 invoices per 24 hours per sender tenant.
  • 10 distinct recipients per 24 hours per verified sender, and 20 per source IP. Further invoices to a recipient already invoiced that day do not count.

On 429 the response body includes retry_after_seconds, reset_at, and limit_code (ip_requests_per_min, ip_invoices_per_hour, tenant_burst_per_min, tenant_invoices_per_24h, sender_recipients_per_24h, or ip_recipients_per_24h) so callers can back off correctly.

After 3 tenant hard-limit hits in 1 hour, invoice issuing for that sender is temporarily soft-blocked for 24 hours. Soft-blocked requests return 403 tenant_soft_blocked with retry_after_seconds, reset_at, and details.auto_lift_at. The shared anonymous LLM tenant is excluded from tenant soft-blocking; anonymous traffic is governed by IP limits, Turnstile, and LLM spend caps instead.

#Format selection

The output format is picked from a deterministic priority chain. See jurisdictions for the full table and the format_override enum.

#OpenAPI

The full OpenAPI 3.1 document is served at /api/v1/openapi.json. Generate clients with openapi-typescript, oapi-codegen, or any tool that consumes OpenAPI 3.1.

#See also

  • CLI reference — the same API wrapped in a commander-based npm CLI.
  • MCP server — the same API exposed as MCP tools for LLM clients.
  • Skill — curl-based helper scripts for Claude Code and OpenAI Codex.