Skip to content

API contract

Sancho recognizes documents through a pluggable OCR backend. Parseur is the default, but a self-hosted deployment can point at its own service — it only has to expose the two HTTP endpoints described below.

If you are starting the implementation, begin with the Building an OCR backend guide.

API environment variables:

Variable Description
OCR_PROVIDER parseur (default) or custom.
OCR_CUSTOM_BASE_URL Base address of the service, e.g. https://ocr.company.local. Required for custom.
OCR_CUSTOM_SUBMIT_PATH Path of the endpoint that accepts a document. Defaults to /ocr/documents.
OCR_CUSTOM_STATUS_PATH Path of the status endpoint; the job id is appended to it. Defaults to /ocr/documents.
OCR_CUSTOM_API_KEY Token sent in the Authorization header. Required for bearer and raw, unused for none and jwt_hs256.
OCR_CUSTOM_AUTH_SCHEME none, bearer (default), raw or jwt_hs256. See Authentication.
OCR_CUSTOM_JWT_SECRET Secret shared with the OCR service, at least 24 characters. Required for jwt_hs256.
OCR_CUSTOM_JWT_ISSUER The token’s iss value. Defaults to this deployment’s API address.
OCR_CUSTOM_JWT_AUDIENCE The token’s aud value. Defaults to the host of OCR_CUSTOM_BASE_URL.
OCR_CUSTOM_JWT_TTL_S Token lifetime in seconds. Defaults to 300, range 180–3600 — a token has to outlive the whole upload it authenticates.
OCR_POLL_INTERVAL_MS Delay between polls. Defaults to 3000.
OCR_POLL_MAX_ATTEMPTS Maximum number of polls. Defaults to 200 (10 minutes at the default interval).

The address is concatenated literally: OCR_CUSTOM_BASE_URL + path. The base may therefore carry its own prefix (https://ocr.company.local/api) and it will not be lost.

An invalid configuration stops the API from starting: custom without OCR_CUSTOM_BASE_URL, parseur without PARSEUR_API_KEY, bearer or raw without OCR_CUSTOM_API_KEY, jwt_hs256 without a secret, and a poll budget (OCR_POLL_INTERVAL_MS × OCR_POLL_MAX_ATTEMPTS) longer than 10 minutes.

OCR_CUSTOM_AUTH_SCHEME picks one of four modes:

Mode Authorization header
none no header — for a service that verifies nothing
bearer (default) Bearer <OCR_CUSTOM_API_KEY>
raw <OCR_CUSTOM_API_KEY> — for gateways that match the header verbatim, like n8n’s Header Auth
jwt_hs256 Bearer <token> — an HS256 token signed afresh for every request

The choice is validated at startup: bearer and raw require OCR_CUSTOM_API_KEY, and jwt_hs256 requires a secret — issuer and audience have defaults. An unauthenticated service has to be declared as none — a missing token is not read as switching authentication off.

The header is sent to both endpoints: submit and status.

Sancho signs the token with a secret shared with the OCR service (HMAC-SHA256), and the service verifies it with that same secret. The payload:

{
"iss": "https://app.company.local",
"aud": "ocr.company.local",
"sub": "sancho-ocr-client",
"iat": 1774694400,
"exp": 1774694700,
"jti": "b8f3c1de-9d4e-4a51-8b0c-1f2e3d4a5b6c"
}

iss and aud fill themselves in — this deployment’s API address and the OCR service’s host — until you name them explicitly. The JOSE header is {"alg":"HS256","typ":"JWT"}. A token lives for OCR_CUSTOM_JWT_TTL_S seconds (300 by default) and is never cached — every request, including every status poll, gets a fresh one.

The secret is symmetric: either side can mint a valid token. The short lifetime protects against replaying a captured request, but not against the secret itself leaking — treat it like a password. At least 24 characters — anything shorter stops the API from starting; 32 (openssl rand -hex 16) is what we ask for. Length alone guarantees nothing: the secret has to be random, because a guessable one can be cracked offline from a single captured token.

On the Webhook node set Authentication: JWT Auth and create a credential:

  • Key Type — Passphrase
  • Secret — the same value as OCR_CUSTOM_JWT_SECRET
  • Algorithm — HS256

Attach the credential to both Webhook nodes — n8n validates authentication separately on each.

n8n checks only the signature and exp. iss, aud, sub and jti are sent but not verified; if they must hold, add an explicit check inside the workflow.

The raw mode is incompatible with JWT Auth: n8n reads the token as the second part of the header (authHeader.split(" ")[1]), so a token without the Bearer prefix fails with a 401.

Return 401 for a missing token and 403 for an invalid one — exactly what n8n’s built-in verification does. Do not return 5xx: Sancho retries polls after a 5xx, so the document would burn its whole budget before failing.

An n8n credential holds exactly one secret, so old and new cannot be accepted at the same time. The swap is simultaneous: in a maintenance window, replace the secret in n8n and OCR_CUSTOM_JWT_SECRET in Sancho. Documents submitted mid-swap will fail and have to be resent.

POST {OCR_CUSTOM_BASE_URL}{OCR_CUSTOM_SUBMIT_PATH}

multipart/form-data request:

Field Description
file Document bytes (PDF or image); the file name and MIME type are preserved.
documentType purchase_order or goods_received_source.

Response 202 (any 2xx is accepted):

{ "id": "3f1c8b12-9d4e-4a51-8b0c-1f2e3d4a5b6c", "status": "pending" }

id is any identifier unique within your service — a UUID is ideal. It must be opaque and URL-safe: it goes into the status endpoint’s path, so characters like / or a space are percent-encoded (%2F, %20), and many servers reject %2F inside a path segment. Do not use document numbers.

status may be pending, processing, queued, ready or error; omitting the field means pending. Returning ready here is allowed — Sancho still makes one poll, because the submit response carries no data field.

Sancho never retries this request automatically — a repeat would mean a second job for the same document.

GET {OCR_CUSTOM_BASE_URL}{OCR_CUSTOM_STATUS_PATH}/{id}

While processing:

{ "id": "3f1c8b12-9d4e-4a51-8b0c-1f2e3d4a5b6c", "status": "processing" }

Once finished:

{
"id": "3f1c8b12-9d4e-4a51-8b0c-1f2e3d4a5b6c",
"status": "ready",
"data": { "documentNumber": "ZAM/1/2026", "items": [] }
}

On failure:

{ "id": "3f1c8b12-9d4e-4a51-8b0c-1f2e3d4a5b6c", "status": "error", "message": "Could not read the document" }

Any non-2xx response (including 404 for an unknown id) is treated like a connection failure: Sancho keeps polling within the remaining budget and marks the document errored once that budget runs out.

data must validate against the JSON Schema matching the submitted documentType:

  • purchase-order.schema.json — documentType: purchase_order
  • goods-received-source.schema.json — documentType: goods_received_source — any source document a goods receipt can be inferred from: a goods-issued (WZ) note, a purchase invoice, a delivery note. Besides the header and items, the schema carries a kind classification (invoice / goodsIssuedNote / other), item prices (priceNet, valueNet, vatRate), and deliveryCostNet — the transport cost, returned instead of a service line in items

Rules common to both schemas:

  • Every field is optional — omit whatever the parser did not read.
  • An omitted scalar is treated as null, omitted items as [].
  • Unknown keys are ignored.
  • Dates are strings in YYYY-MM-DD format.
  • Numbers are JSON numbers, not strings.
  • Tax ids are best sent as digits only — Sancho strips spaces and dashes anyway.

A document whose result fails validation is marked as errored (SchemaValidationError) and goes no further.