Document processing (Job API)

Job lifecycle

JOB_STATUS_NEW → JOB_STATUS_FILE_PROCESSING → JOB_STATUS_OCR → JOB_STATUS_LLM → JOB_STATUS_COMPLETE | JOB_STATUS_PARTIAL | JOB_STATUS_FAILED
  • JOB_STATUS_NEW — the job has been created and queued.
  • JOB_STATUS_FILE_PROCESSING — files are being downloaded, archives unpacked, and formats normalized into a processable form. Can transition directly to JOB_STATUS_FAILED if all sources are unreachable or a file cap is exceeded (reason in the job’s error field).
  • JOB_STATUS_OCR — text recognition for each file.
  • JOB_STATUS_LLM — the recognized text is sent to the model with your prompts.
  • JOB_STATUS_COMPLETE — no errors at the OCR or LLM stage.
  • JOB_STATUS_PARTIAL — at least one successful model (LLM) answer, but also at least one error at the OCR or LLM stage (check the file-level errors in the result).
  • JOB_STATUS_FAILED — errors prevented any file from reaching a successful model answer: either a failure during file download/unpacking (reason in the job-level error field; ocr[]/llm[] are empty), or no file reached a successful result at the OCR or LLM stage.

Processing is asynchronous: poll the job status via GET /v1/jobs/{id} until the job reaches a terminal status.

Uploading files

You can specify a job’s source in two ways:

  1. Public URL — pass the URL in sourceUrls when creating the job. hotdoc downloads the file (download timeout: 30 s, up to 3 redirects).
  2. Direct upload — upload the file, then use the returned URL in sourceUrls:
    • POST /v1/jobs/upload (multipart/form-data) — uploads a single file over HTTP. This is a standalone HTTP endpoint (not grpc-gateway). The response is JSON in snake_case: {"url": "…", "name": "…", "size_bytes": 12345}. This endpoint’s error body is {"code": <int>, "message": "…"}, with no details array. The size limit is set by config (grpc.maxRecvMsgBytes; 20 MiB in the current deployment), not a code default.
    • gRPC method Upload (client-streaming) — a streaming upload (single-message limit: 20 MiB).

Prompts and data extraction

Data extraction is driven by text prompts, not by a schema. In the prompts field you pass an array of instructions. At the LLM stage, each file’s recognized text is taken, split into chunks if needed, and sent to the model together with your prompt. The model’s answer is returned as text per file (and per chunk, if the file was split).

Chunk splitting is structure-aware: the text is cut along the boundaries of its format’s structure (Markdown, HTML, XML, or plain) — tables are not torn mid-row (and if a table doesn’t fit whole, its header row is repeated in each chunk), and section-heading context is preserved. The chunk size in tokens is set by neural.chunkBudgetTokens (see “Connecting a model”); if unset, the service’s conservative default applies. Each chunk is a separate model call with a full copy of the prompt and a separate llm[] row (chunkIndex / chunkTotal). Reassembling the answer from chunks in chunkIndex order is done on your side — the service does not merge chunks into a single result.

To get structured data, ask for it directly in the prompt — for example: “Return the result as JSON with the following fields: …”. Your prompt and the model you chose determine the validity and shape of the JSON; hotdoc neither imposes nor validates a schema.

Prompt tips:

  • list the fields you need explicitly and unambiguously;
  • specify the output format right in the prompt text;
  • keep the prompt size limit in mind — 64 KiB (see “Limits”).

Note. The <…> placeholders in the templates below mark spots for you to fill in: hotdoc does not substitute them automatically — the prompt is sent to the model exactly as written. Your prompt and the model you choose determine the output structure; there’s no server-side schema validation.

Prompt quality drives your results. How well extraction works depends as much on your prompt as on the model you choose — often more. A vague prompt produces vague output even on a top-tier model, while a precise, well-structured prompt gets reliable results even from smaller, cheaper models. Treat the template below as a starting point, not a finished prompt: take it, describe your document type, your task, and the exact output you need, then ask a capable model to turn it into a prompt tailored to your case — keeping this structure while sharpening the rules, edge cases, and output validation for your data. The meta-prompt for that is at the end of this section.

Example: extracting invoice / order / receipt fields

prompts[0] text:

TASK
Extract structured fields from a single procurement/accounting document and return them strictly as JSON.

IMPORTANT
Your answer must contain ONLY JSON. Do not add any comments, explanations, or surrounding text before or after the JSON.

1. INPUT
The recognized text of a single document follows the "---" marker below (the OCR output is HTML). It is the only source. The document may be an <type: invoice / purchase order / receipt> and may contain stamps, signatures, and multi-row line-item tables. The text may be truncated or split into chunks — work only with the text you are given and never assume content you cannot see.

2. OUTPUT JSON FORMAT
Return a single JSON object matching this schema (the inline comments are explanatory — do not include them in the output):
{
  "doc_type": "string",   // one of: invoice | purchase_order | receipt | unknown
  "number": "string",
  "date": "string",       // ISO 8601: YYYY-MM-DD
  "supplier": {
    "name": "string",
    "tax_id": "string"    // e.g. US EIN or EU VAT ID, as printed
  },
  "items": [
    { "name": "string", "qty": number, "price": number, "amount": number }
  ],
  "total": number,
  "currency": "string"    // ISO 4217, e.g. USD, EUR
}

3. EXTRACTION RULES
- doc_type: classify from the title, headers, and content. If it is none of the listed types, set "unknown" and still fill any fields you can.
- number / date: the document's own number and issue date. Convert the date to ISO 8601 (YYYY-MM-DD).
- supplier: the selling/issuing party, not the buyer. tax_id: the supplier's tax identifier, exactly as printed.
- items: one object per line item, in document order. Keep "name" exactly as written, including specifications and units that identify the item.
- qty / price / amount / total: return as JSON numbers — strip thousands separators and currency symbols, use a dot as the decimal separator ("1,200.50" -> 1200.5).
- currency: ISO 4217 code. If only a symbol is present, map it ("$" -> "USD", "€" -> "EUR"). If it cannot be determined, use null.

4. PROCESSING REQUIREMENTS
- Use ONLY the provided document text. Do not add external knowledge or infer values that are not present.
- Do NOT guess, complete, or reformat values beyond the normalization explicitly required above.
- Field not found -> null for scalars (including supplier sub-fields), [] for "items". Never drop a schema key.
- Analyze the entire document, including tables and appendices. A reference to an external attachment is not a line item.
- Be literal and deterministic: the same input must always produce the same output.

5. RESPONSE FORMAT
- Return ONLY the valid JSON object described above.
- No markdown, no code fences, no text before or after the JSON.

REMEMBER
Your answer must start with "{" and end with "}". Nothing else. If the document is not one of the expected types, return the schema with "doc_type": "unknown" and whatever fields you could extract.

JSON envelope (primary — on the verified Xiaomi mimo-v2-flash):

{
  "sourceUrls": ["<YOUR_FILE_URL>"],
  "title": "Invoice <number>",
  "prompts": ["<THE ENTIRE TEMPLATE ABOVE, AS A SINGLE STRING>"],
  "ocr": { "provider": "NEURAL_CLIENT_TYPE_MISTRAL", "model": "mistral-ocr-latest", "providerKey": "<YOUR_KEY>" },
  "neural": {
    "type": "NEURAL_CLIENT_TYPE_XIAOMI",
    "model": "mimo-v2-flash",
    "apiKey": "<YOUR_PROVIDER_KEY>",
    "reasoningEffort": "low"
  }
}

Header: Authorization: Bearer <YOUR_HOTDOC_KEY>.

Request fieldWhat it is”Variable”
Authorizationyour hotdoc API keyAPI access key
sourceUrls[]file URLsdocument links
prompts[0]the entire template as a single stringstructured prompt
neural.type / neural.modelprovider and modelmodel
neural.apiKeyprovider key (BYOK)model key
neural.reasoningEffortoptional minimal/low/medium/highreasoning depth
ocr.providerOCR provider enum (e.g. NEURAL_CLIENT_TYPE_MISTRAL)OCR provider
ocr.modelOCR model identifier; requiredOCR model
ocr.providerKeyProvider key for OCR (BYOK); accepted as input only, never returnedOCR key

More examples (briefly). Same mechanics — only the prompt text and the expected answer shape in llm[].content differ:

  • Classification. Prompt: “Determine the document type: invoice / contract / receipt / letter / other. Return a single word from the list, with no explanation.” Answer: a single word (e.g. contract).
  • Contract terms. Prompt: “Extract: parties, subject, amount, term, and termination conditions. Return JSON matching the schema {parties[], subject, amount, term, termination}. Field not found → null.” Answer: JSON matching the schema.
  • Summary. Prompt: “Summarize the document in 3–5 sentences. No bullet lists.” Answer: prose text.

Build your own prompt (meta-prompt)

The fastest way to get a high-quality prompt is to have a capable model write it for you. Hand it the meta-prompt below: paste in our example as the reference structure, the output shape you need (JSON/CSV/Markdown), and a description of your context and task — and you get a ready-to-use hotdoc prompt back. Fill in the bracketed blocks; the model handles the rest.

You are a senior prompt engineer. Build a production-grade extraction prompt that will be sent to a document-processing model through the hotdoc API. The model receives the OCR'd text (HTML) of a single document and must return data in a strict, machine-parseable format.

WHAT I'M GIVING YOU

1) REFERENCE PROMPT — the structure and style to follow. Preserve its section anatomy.
<<<REFERENCE_PROMPT
[paste the hotdoc example prompt here]
REFERENCE_PROMPT

2) TARGET OUTPUT — the exact shape I need back: a JSON schema/sample, CSV columns, or Markdown layout.
<<<TARGET_OUTPUT
[paste your desired JSON / CSV / Markdown here]
TARGET_OUTPUT

3) DOMAIN & CONTEXT — what these documents are, where they come from, and their quirks (languages, layouts, stamps, tables, common OCR errors).
<<<CONTEXT
[describe your documents and domain]
CONTEXT

4) TASK — exactly what to extract or produce, plus the business rules, definitions, and edge cases that matter.
<<<TASK
[describe the task and rules]
TASK

5) OUTPUT FORMAT — one of: JSON | CSV | Markdown. Default: JSON.
<<<FORMAT
JSON
FORMAT

HOW TO BUILD THE PROMPT
1. Study the domain and task deeply before writing. Infer the edge cases a careful human reviewer would catch — ambiguous fields, duplicates, ranges, units, missing data, multi-row tables, appendices — and address each one explicitly.
2. Keep the REFERENCE PROMPT's anatomy: a one-line TASK, an IMPORTANT "only the target format" rule, then numbered sections (INPUT, OUTPUT FORMAT, EXTRACTION/PROCESSING RULES field by field, PROCESSING REQUIREMENTS, RESPONSE FORMAT), and a final REMEMBER reinforcement.
3. Make the output contract unambiguous for the chosen format:
   - JSON: give the full schema with types and nullability, mark required vs optional keys, forbid any text/markdown/code fences outside the JSON, and require the answer to start with "{" (or "[") and end with "}" (or "]").
   - CSV: fix the exact column order and header row, the delimiter, the quoting/escaping rule, and how empty values are written; one record per row, no prose.
   - Markdown: fix the exact headings/table columns and forbid any content outside that layout.
4. Pin the data discipline: use only the provided document text; never invent, guess, or reformat beyond the normalization you explicitly define; specify number, date, and unit normalization; define how "not found" is represented (null / empty / skipped) and how duplicates are handled; preserve source values verbatim where identity matters.
5. Account for hotdoc specifics: the model sees one document's OCR'd HTML, possibly truncated or split into chunks; do not rely on any temperature setting — enforce determinism through wording ("be literal and deterministic"); the prompt is sent verbatim, so resolve every "<placeholder>" yourself.
6. Self-check before finishing: re-read the TARGET OUTPUT and confirm the prompt forces exactly that shape, that every field has a rule, and that a small, cheap model could follow it without guessing.

OUTPUT
Return ONLY the finished prompt, ready to paste into hotdoc's "prompts" array — no explanation, no preamble, no code fences.

Connecting a model (BYOK)

The LLM stage runs on your provider key. The configuration is passed in the neural object when you create a job:

FieldRequiredDescription
typeyesprovider (see list below)
modelyesmodel identifier; passed to the provider as-is
apiKeyyesyour provider key; accepted as input only, never returned in responses
reasoningEffortnoreasoning-depth hint; allowed values: minimal, low, medium, high (empty = off). An invalid value → 400 error. Whether it’s honored depends on the provider/model.
chunkBudgetTokensnotoken budget for a single model call: covers both the prompt and the document text in one chunk. 0/unset → the service’s conservative default. Range: 160002000000; a value out of range → 400. Set it to your model’s context window — only you know it. The response always returns the actual effective budget (including when you rely on the default): the service reserves a small allowance for chat-template tokens, so the returned value is slightly lower than what you set.

Supported providers:

Providerneural.type value
OpenAINEURAL_CLIENT_TYPE_OPENAI
Anthropic (Claude)NEURAL_CLIENT_TYPE_CLAUDE
xAI (Grok)NEURAL_CLIENT_TYPE_GROK
TogetherNEURAL_CLIENT_TYPE_TOGETHER
DeepSeekNEURAL_CLIENT_TYPE_DEEPSEEK
XiaomiNEURAL_CLIENT_TYPE_XIAOMI
MistralNEURAL_CLIENT_TYPE_MISTRAL
OpenRouterNEURAL_CLIENT_TYPE_OPENROUTER

Through NEURAL_CLIENT_TYPE_OPENROUTER you get models from many vendors that don’t have a direct integration.

You pay the provider directly at their rate — hotdoc adds no markup on tokens or OCR.

Idempotent retries

To retry job creation safely, generate one idempotencyKey (a UUID) and reuse it across retries of the same request:

POST /v1/jobs
{ "sourceUrls": ["..."], "ocr": { ... }, "neural": { ... }, "idempotencyKey": "3f1c…" }

Retrying with the same key and identical parameters returns the original job; changing any parameter under the same key returns 400. Use a new key for a genuinely new job.

Webhooks

Webhooks let you avoid polling: the service will POST your endpoint once the job finishes. This is entirely optional — if you don’t set the fields, API behavior is unchanged.

Setup

When creating a job (POST /v1/jobs), pass one or both optional fields:

FieldTypeDescription
webhookUrlstringAbsolute URL of your endpoint (http:// or https://). Accepted as input only — never returned in responses.
webhookSecretstringOptional HMAC signing secret. Accepted as input only — never returned in responses; stored encrypted at rest and deleted with the job (see “Security and data”).

Example:

{
  "sourceUrls": ["https://example.com/invoice.pdf"],
  "prompts": ["Extract the total."],
  "ocr": { "provider": "NEURAL_CLIENT_TYPE_MISTRAL", "model": "mistral-ocr-latest", "providerKey": "..." },
  "neural": { "type": "NEURAL_CLIENT_TYPE_XIAOMI", "model": "mimo-v2-flash", "apiKey": "..." },
  "webhookUrl": "https://your-service.example.com/hooks/hotdoc",
  "webhookSecret": "my-secret-value"
}

When the webhook fires

Once per job — on the first transition to a terminal status (JOB_STATUS_COMPLETE, JOB_STATUS_PARTIAL, or JOB_STATUS_FAILED). Delivery itself is at-least-once (duplicates are possible on failure); see “Delivery guarantees.” The webhook doesn’t carry the processing result; it signals completion only. Fetch the full result with the usual GET /v1/jobs/{id}/result.

Request body

The service sends a POST to your webhookUrl with Content-Type: application/json and a JSON body:

{
  "job_id":      "15b07304-...",
  "account_id":  "a1b2c3d4-...",
  "status":      "complete",
  "finished_at": "2026-06-24T12:34:56Z"
}
FieldTypeDescription
job_idstringJob UUID
account_idstring (account identifier)Account identifier
statusstringOne of: complete, partial, failed
finished_atstringJob completion time in RFC3339 format (UTC)

Signature verification

When webhookSecret is set, every request includes two additional headers:

HeaderExample valueDescription
X-Hotdoc-Timestamp1750765200Unix time of delivery (seconds)
X-Hotdoc-Signaturesha256=a3f4...HMAC-SHA256 signature

Signing algorithm:

signature = "sha256=" + hex( hmac_sha256(secret, "<timestamp>.<body>") )

where <timestamp> is the string form of the Unix time from X-Hotdoc-Timestamp, <body> is the raw request body (bytes as received), and . is the separator. hex is lowercase; secret is used as UTF-8 bytes.

How to verify on your side:

  1. Extract the X-Hotdoc-Timestamp value.
  2. Compute hmac_sha256(secret, "<X-Hotdoc-Timestamp value from step 1>.<raw request body>"), encode as lowercase hex, and prepend sha256=.
  3. Compare it to X-Hotdoc-Signature using a constant-time comparison (hmac.Equal / crypto/subtle.ConstantTimeCompare or equivalent).
  4. Reject the request if the timestamp is too old (recommended tolerance: 5 minutes).

If webhookSecret is not set, the X-Hotdoc-Timestamp and X-Hotdoc-Signature headers are not sent.

Retry policy

If your endpoint is unreachable or returns an error, the service retries delivery on the following schedule:

AttemptDelay before next
1 → 21 minute
2 → 35 minutes
3 → 415 minutes
4 → 530 minutes
5 → 630 minutes
6— (final; delivery is marked failed afterwards)

Total: up to 6 attempts.

Permanent errors (4xx other than 408/429, unusable URL, SSRF block) are not retried: the delivery is immediately marked as failed. A 2xx response is considered a success.

Delivery guarantees

Delivery is at-least-once: in most cases your endpoint receives exactly one call, but retries can cause duplicate delivery on failures. Deduplicate events on your side using job_id.