Following system theme

#Troubleshooting

Every non-2xx Scribo response shares the same envelope:

{
  "error": {
    "code": "...",
    "message": "...",
    "details": { } 
  }
}

The CLI maps these to sysexits-style exit codes. The skill helper scripts use the same mapping.

#rate_limited (429)

{
  "error": {
    "code": "rate_limited",
    "message": "Rate limit exceeded (ip_invoices_per_hour).",
    "retry_after_seconds": 3600,
    "reset_at": "2026-05-18T15:00:00Z",
    "limit_code": "ip_invoices_per_hour"
  }
}

limit_code values:

  • ip_requests_per_min — too many requests from this network. Wait retry_after_seconds.
  • ip_invoices_per_hour — too many invoice-generation requests from this network. Wait retry_after_seconds.

CLI exit code: 75 (EX_TEMPFAIL).

#tenant_invoice_quota_exceeded (429)

{
  "error": {
    "code": "tenant_invoice_quota_exceeded",
    "message": "Rate limit exceeded (tenant_invoices_per_24h).",
    "retry_after_seconds": 7200,
    "reset_at": "2026-05-18T17:00:00Z",
    "limit_code": "tenant_invoices_per_24h"
  }
}

limit_code values:

  • tenant_burst_per_min — this sender tenant generated too many invoices in the current minute.
  • tenant_invoices_per_24h — this sender tenant reached the daily invoice quota.

Wait until reset_at before retrying. After repeated tenant hard-limit hits, Scribo may return tenant_soft_blocked instead.

CLI exit code: 75 (EX_TEMPFAIL).

#tenant_soft_blocked (403)

{
  "error": {
    "code": "tenant_soft_blocked",
    "message": "Tenant is temporarily soft-blocked.",
    "retry_after_seconds": 86400,
    "reset_at": "2026-05-19T15:00:00Z",
    "limit_code": "tenant_hard_limit_hits_per_hour",
    "details": {
      "reason_code": "tenant_hard_limit_hits_per_hour",
      "auto_lift_at": "2026-05-19T15:00:00Z"
    }
  }
}

This sender tenant hit hard invoice limits repeatedly. Do not retry in a loop; wait until details.auto_lift_at.

CLI exit code: 75 (EX_TEMPFAIL).

#email_verification_required (401)

POST /api/v1/invoices for a sender email that has not been verified yet, with no X-Email-Verification-Token header and no web session.

Recovery: run the email verification flow — request a challenge, redeem the 6-character code for a verification_token, retry with the X-Email-Verification-Token header. The CLI, MCP server, and skill orchestrate this automatically.

#verification_invalid (400)

The redeem endpoint rejected the code — wrong, expired (15 min), or revoked. The response is deliberately uniform across all three causes. Skill helper exit code: 11.

Recovery: re-request a challenge and use the code from the newest email. Sends are rate-limited (1 per 30 s, 5 per hour per email).

#verification_email_mismatch (403)

The verification token (or session) proves a different email than sender.contact_email in the payload.

Recovery: verify the actual sender address, or fix sender.contact_email. One token is valid for exactly one sender email.

#turnstile_required (401)

The Scribo web chat requires a Cloudflare Turnstile token before the first LLM call. The public API, CLI, MCP server, and skill scripts are not CAPTCHA-gated and should not send a Turnstile header.

Recovery: refresh the web UI and complete the browser check.

If a headless client sees this error, it is calling the web-chat route by mistake; switch to /api/v1/* or /mcp.

#idempotency_key_mismatch (422)

{
  "error": {
    "code": "idempotency_key_mismatch",
    "message": "Idempotency-Key '…' was used previously with different inputs"
  }
}

The CLI and skill helpers auto-mint Idempotency-Key from a SHA-256 of the payload, so same-payload retries return the cached response. This error fires when the caller supplied an explicit --idempotency-key and the payload changed.

Recovery: keep the original payload, or use a fresh idempotency key, or remove the explicit key and let the helpers auto-mint.

#validator_failed

Two shapes:

  1. Pre-generation rejection (400) — required field missing or format-specific constraint violated before Invopop is even called.
  2. Schematron failure (201 with validator_summary.valid == false) — Invopop generated the invoice but the validator flagged rule violations. The PDF / XML is returned but the recipient's tax authority may reject it. The validator_summary.errors array has [{ path, rule, message }].

Common rule families

Rule Trigger Fix
BR-CO-09 Sender VAT ID country prefix doesn't match sender.country_code. Correct the prefix or the country code.
BR-CO-27 Sum of line totals doesn't match unit_price * quantity - discount. Recompute line totals client-side.
BR-AE-01..10 AE (reverse charge) without recipient VAT ID, or other AE constraints. Provide recipient VAT ID; verify both parties are EU-registered.
BR-IC-01..12 K (intra-community supply) constraints violated. Recipient must be in a different EU member state with a valid VAT ID.
BR-S-08 S line total tax amount doesn't match the BT-118 grouped tax breakdown. Float precision; the helpers forward strings — make sure unit_price is a decimal string.

Surface the path and rule to the user verbatim. Don't try to rewrite the payload silently — the user needs to know which field they got wrong.

See tax-codes for the EN 16931 code constraints.

#unsupported_jurisdiction (400)

The country-resolution chain resolved to a jurisdiction outside the Phase 1 allowlist. Scribo currently emits invoices for Germany (DE) and the United States (US) only — France, Spain, Belgium and the rest of the EU are Phase 2 and not yet wired.

The error payload includes the resolved code and the live allowlist:

{
  "error": {
    "code": "unsupported_jurisdiction",
    "message": "Jurisdiction 'FR' is not supported. Scribo currently emits invoices for: DE, US.",
    "details": {
      "resolved_jurisdiction": "FR",
      "supported_jurisdictions": ["DE", "US"]
    }
  }
}

Call /api/v1/jurisdictions for the live list and re-ask the user for a DE or US sender.

#invoice_not_found / artifact_not_found (404)

invoice_not_found — no invoice metadata for that ID visible to this caller. Cross-tenant probes deliberately return the same 404 as a genuinely unknown ID. artifact_not_found — metadata exists but the stored bytes are missing on /invoices/{id}/download.

Recovery: re-use the exact download_url from the create response (don't construct your own). CLI exit code: 66 (EX_NOINPUT).

#unsupported_kleinunternehmer (400)

A § 19 UStG small-business invoice (no sender.tax_id, only tax_registration_id, with an exempt E line) is not yet supported — the underlying Invopop addons don't map the German tax number into BT-32 yet, so EN 16931 BR-CO-26 would reject it.

Recovery: supply sender.tax_id (VAT ID) if the seller has one; otherwise this flow is blocked until the upstream addon update lands.

#Over 500 line items — invalid_input (400)

Scribo caps line_items at 500 entries. A 501+ array is surfaced as a standard invalid_input response with the path line_items and the message "Array must contain at most 500 element(s)". The cap is enforced at the Zod boundary before the invoice ever reaches Invopop. Split the invoice into multiple smaller ones.

(A dedicated 413 payload_too_large status is reserved for future revisions — today the 500-line cap surfaces as the standard 400 invalid_input shape.)

#Generic 5xx (internal_error)

{
  "error": {
    "code": "internal_error",
    "message": "Internal error",
    "correlation_id": "abc-..."
  }
}

Retry with the same idempotency key. If the error persists, the response includes a correlation_id — include it when you contact support.

#CLI / helper exit codes

The CLI and skill scripts map the error envelope to sysexits-style exit codes. See cli#exit-codes for the full table.

#See also