API reference
Base URL: https://api.hotdoc.io. Request and response bodies are JSON; field names are in camelCase. Exceptions: POST /v1/jobs/upload (response) and the webhook request body — both use snake_case.
Processing endpoints
| Method | Endpoint | Purpose |
|---|---|---|
POST | /v1/jobs | create a job |
GET | /v1/jobs/{id} | job status and file list (without recognition results) |
GET | /v1/jobs/{id}/result | full result: recognized text and model answers per file |
GET | /v1/jobs | list account jobs (pagination: pageSize, pageToken, filter statusEq) |
POST | /v1/jobs/upload | upload a single file (multipart/form-data) |
POST /v1/jobs — create a job
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
sourceUrls | string[] | yes | file URLs to process |
prompts | string[] | no | model instructions (only the first prompt runs); without prompts, the LLM is not called |
neural | object | yes | model configuration (see “Connecting a model”) |
ocr | object | yes | OCR provider configuration (BYOK); always required (see “OCR configuration”) |
extractionMode | enum | no | EXTRACTION_MODE_UNSPECIFIED | EXTRACTION_MODE_HYBRID | EXTRACTION_MODE_OCR_ALWAYS; omitted/UNSPECIFIED inherits the account default (itself defaulting to HYBRID) |
responseSchema | string | no | optional JSON Schema (raw string) constraining the model’s JSON answer; self-contained (internal #/$defs only), ≤128 KiB, depth ≤64, ≤10000 nodes; an invalid schema is rejected with 400 at creation (see “Structured output” in the Job API docs) |
merge | object | no | optional server-side merge of per-chunk answers into one whole-document result; off by default; see “Merge options” below and “Merging chunked results” in the Job API docs |
consensus | object | no | optional k-run consensus voting per chunk; requires responseSchema (400 otherwise); off by default; see “Consensus options” below and “Consensus runs” in the Job API docs |
title | string | no | arbitrary job name |
metadata | map<string,string> | no | arbitrary string key-value pairs |
webhookUrl | string | no | absolute http/https endpoint to notify on job completion (see “Webhooks”) |
webhookSecret | string | no | optional HMAC secret for signing webhook requests; accepted as input only, never returned in responses |
idempotencyKey | string | no | client key for safe create retries; max 255 chars (see “Idempotency”) |
neural and ocr are always required, even when prompts is empty. With an empty prompts, the model isn’t called: each file is marked JOB_LLM_STATUS_SKIPPED with skipReason=no_prompt, and on successful OCR the job finishes as JOB_STATUS_COMPLETE.
The response is the created job in status JOB_STATUS_NEW; responseSchema is echoed back on it (empty if none was set); consensus is echoed back as a populated object — mode: "CONSENSUS_MODE_UNSPECIFIED" when none was set, never an omitted or null field.
OCR configuration
ocr selects the recognition (OCR) backend per request (BYOK):
| Field | Type | Required | Description |
|---|---|---|---|
ocr.provider | enum | yes | one of NEURAL_CLIENT_TYPE_MISTRAL, _OPENAI, _CLAUDE, _DEEPSEEK, _GROK, _TOGETHER, _OPENROUTER, _XIAOMI |
ocr.model | string | yes | the provider’s model id, e.g. mistral-ocr-latest (Mistral) or the chosen provider’s vision model |
ocr.providerKey | string | yes | BYOK key for the OCR provider; input only, never returned |
Mistral is the typical OCR backend (mistral-ocr-latest); other providers run
vision-chat OCR. The key is stored encrypted and deleted with the job.
Note. Extraction mode is a top-level extractionMode field, not part of ocr — it controls whether OCR runs at all (EXTRACTION_MODE_OCR_ALWAYS) or is applied per file at the converter’s discretion (EXTRACTION_MODE_HYBRID, the default).
Which model should you use? Compare intelligence, per-token price and providers side by side in the LLM comparison, then set the model accordingly.
Merge options (merge)
merge is optional and off by default — set merge.enabled to opt in. All other merge.* fields are ignored while enabled is false/omitted.
| Field | Type | Required | Description |
|---|---|---|---|
merge.enabled | bool | no | turns server-side merging on for this job |
merge.scope | enum | no | MERGE_SCOPE_UNSPECIFIED | MERGE_SCOPE_FILE (default) | MERGE_SCOPE_JOB; one merged[] entry per file, or one across all files |
merge.conflictPolicy | enum | no | MERGE_CONFLICT_POLICY_UNSPECIFIED | MERGE_CONFLICT_POLICY_FIRST_NON_NULL (default) | MERGE_CONFLICT_POLICY_MAJORITY |
merge.dedupeBy | string[] | no | JSON field names identifying a unique record, for cross-file content dedup; JSON + responseSchema only; at most 32 field names — more are rejected with a 400 at job creation; empty/omitted → technical dedup only |
merge.format | enum | no | MERGE_FORMAT_UNSPECIFIED | MERGE_FORMAT_AUTO (default) | MERGE_FORMAT_JSON | MERGE_FORMAT_XML | MERGE_FORMAT_HTML | MERGE_FORMAT_MARKDOWN | MERGE_FORMAT_TEXT; overrides automatic per-file format detection |
See “Merging chunked results” in the Job API docs for the full walkthrough (scope, conflict resolution, the two kinds of dedup).
Consensus options (consensus)
consensus is optional and off by default. When set, consensus.mode must be a recognized value and the job must set responseSchema — a job with consensus.mode set but no responseSchema is rejected with 400 at creation.
| Field | Type | Required | Description |
|---|---|---|---|
consensus.mode | enum | no | CONSENSUS_MODE_UNSPECIFIED (disabled, default) | CONSENSUS_MODE_TWO_RUNS | CONSENSUS_MODE_THREE_RUNS | CONSENSUS_MODE_FIVE_RUNS; an unrecognized string value is silently ignored by the gateway — the job runs without consensus, not a 400 |
See “Consensus runs” in the Job API docs for the full walkthrough (modes, reading minAgreement/disagreements, honest caveats).
Idempotency
Send idempotencyKey to make POST /v1/jobs safe to retry. A replay with the
same key and identical request parameters returns the original job (no
duplicate is created). The same key with different parameters is rejected with
400 "idempotency key reused with different request parameters". The key is at
most 255 characters; generate a fresh UUID per logical create.
GET /v1/jobs/{id}/result — result
Returns { "result": { "job": …, "ocr": [...], "llm": [...] } }. Example (abridged):
{
"result": {
"job": {
"id": "15b07304-…", "accountId": "…", "title": "Invoice #42",
"metadata": {}, "status": "JOB_STATUS_COMPLETE", "error": "",
"sourceUrls": ["https://example.com/invoice.pdf"],
"files": ["https://…/files/15b07304-…/invoice.pdf"],
"prompts": ["…"], "neural": { "type": "NEURAL_CLIENT_TYPE_XIAOMI", "model": "mimo-v2-flash", "chunkBudgetTokens": 240000 },
"created": "2026-06-17T16:57:49Z", "updated": "2026-06-17T16:58:05Z"
},
"ocr": [
{ "jobId": "15b07304-…", "file": "https://…/invoice.pdf",
"status": "JOB_OCR_STATUS_DONE", "content": "<p><b>…</b></p>",
"outputFormat": "html", "model": "pdf_fitz", "error": "", "duration": "149508116",
"created": "…", "updated": "…" }
],
"llm": [
{ "jobId": "15b07304-…", "file": "https://…/invoice.pdf",
"promptIndex": 0, "chunkIndex": 0, "chunkTotal": 1,
"status": "JOB_LLM_STATUS_DONE", "skipReason": "",
"model": "mimo-v2-flash", "content": "{\"total\": 15000}",
"error": "", "duration": "601925111", "created": "…", "updated": "…" }
]
}
}
neural in the response doesn’t include apiKey; the Job object has no ocr field — the OCR key is never returned. ocr[].content is the recognized text in the ocr[].outputFormat format; the format depends on the file type: PDF and HTML → html, .xml → xml, .txt → plain, everything else (including images and office files) → markdown. llm[].content is the model’s answer text as-is (your prompt determines the structure; there’s no server-side validation unless the job set responseSchema, in which case llm[].schemaValid/schemaErrors report conformance). In the example above, empty/zero fields ("", {}, 0) are shown for completeness — protojson omits them, so they may be absent from a real response.
ocr[] fields:
| Field | Type | Description |
|---|---|---|
jobId / file | string | job id / file URL |
status | enum | JOB_OCR_STATUS_PENDING / _SKIPPED / _DONE / _FAILED |
content | string | recognized text in the outputFormat format |
outputFormat | string | format of content: markdown / html / xml / plain. Drives the structure-aware chunking at the LLM stage |
model | string | OCR method/engine (e.g. pdf_fitz) |
request / rawData | string | debug: the OCR request and raw response |
error | string | stage error (empty on success) |
duration | string | duration, ns (a number as a string — protojson returns int64 as a string) |
created / updated | string | RFC3339 |
llm[] fields:
| Field | Type | Description |
|---|---|---|
jobId / file | string | job id / file URL |
promptIndex | int | prompt index (currently always 0) |
chunkIndex / chunkTotal | int | chunk number / total chunks (if the text was split) |
status | enum | JOB_LLM_STATUS_PENDING / _SKIPPED / _DONE / _FAILED |
skipReason | string | when SKIPPED: no_prompt / ocr_failed / no_ocr_content / empty_ocr_content |
content | string | model answer (text as-is) |
model | string | the model string from the request |
request / rawData | string | debug |
error | string | stage error (e.g. provider error (category=…, status=400)) |
duration | string | duration, ns (a number as a string — protojson returns int64 as a string) |
created / updated | string | RFC3339 |
schemaValid | bool | present only when the job set responseSchema and the file wasn’t split (chunkTotal = 1): whether content conforms to the schema |
schemaErrors | string[] | conformance violations when schemaValid is false (≤10, ≤512 bytes each) |
llm[].consensus fields (present only when the job set consensus.mode; on consensus jobs, skipped/failed rows still carry consensus: null):
| Field | Type | Description |
|---|---|---|
k | int | runs requested (2 / 3 / 5) |
runsVotable | int | runs that produced a parseable, in-budget JSON answer and took part in the vote |
minAgreement | number | lowest per-field agreement across the row, as a fraction; 0 is a reserved sentinel — no vote happened (runsVotable < 2) or the vote degraded, not “0% agreement” |
incomplete | bool | true when runsVotable < k, or the vote degraded |
disagreements | array | fields the runs didn’t fully agree on, worst-agreement first; each entry: fieldPath (dot-path; "" = document root; may repeat across entries when a path has both a presence dispute and an array-element dispute — render each entry separately, don’t dedupe by path), variants[] (value — compact JSON, rune-safe truncated to 512 bytes; runs — how many votable runs produced it; included — won the vote / is present in the agreed answer, independent of whether its key is textually present — a winning null has included: true even though the key is omitted), variantsDropped (variants clamped off this entry, count only) |
disagreementsDropped | int | disagreement entries clamped off the row (count only; ≤100 entries kept) |
merged[] fields (present only when the job set merge.enabled):
| Field | Type | Description |
|---|---|---|
file | string | file URL this merged answer covers; empty ("") at scope=job |
format | string | format of content: json / xml / html / markdown / text |
content | string | the merged whole-document (or whole-file) answer |
schemaValid | bool | present only when the job set responseSchema: whether the merged content conforms to it |
conflicts | array | fields that disagreed between chunks (≤100 entries, ≤10 competing values each, ≤512 bytes per value); each entry: field, competing values[], sources[] (file + chunk refs) — values[i] corresponds to sources[i] (index-aligned) |
dedupeRemovedTechnical | int | records removed by automatic boundary-overlap dedup |
dedupeRemovedContent | int | records removed by your dedupeBy fields |
mergeIncomplete | bool | true when at least one chunk couldn’t be safely merged and was appended as-is instead |
incompleteChunks | array | the chunk(s) that couldn’t be merged; each entry: file, chunk index |
Note. A machine-readable OpenAPI 3.0.3 description of the Jobs API is published at
/openapi.yaml. It is generated from the service’s protobuf definitions, so it does not drift from the API; this page stays the prose reference. Feed the file to any OpenAPI-compatible client generator.
POST /v1/jobs/upload — upload a file
A standalone HTTP endpoint (not grpc-gateway). Accepts a file via multipart/form-data.
Response (snake_case — an exception to the general camelCase):
{"url": "https://…/files/…/document.pdf", "name": "document.pdf", "size_bytes": 204800}
Use the returned url in sourceUrls when creating a job.
This endpoint’s error body is {"code": <int>, "message": "…"}, with no details array. The file size limit is set by config (grpc.maxRecvMsgBytes; 20 MiB in the current deployment), not a code default.
Versioning
The /v1 path is stable. Backward-incompatible changes ship under a new path (/v2). Additive changes (new optional fields and endpoints) don’t break compatibility and are announced in the Changelog.
Developer resources
- OpenAPI specificationOpenAPI 3.0.3, generated from the service definitions. Feed it to any client generator.