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

MethodEndpointPurpose
POST/v1/jobscreate a job
GET/v1/jobs/{id}job status and file list (without recognition results)
GET/v1/jobs/{id}/resultfull result: recognized text and model answers per file
GET/v1/jobslist account jobs (pagination: pageSize, pageToken, filter statusEq)
POST/v1/jobs/uploadupload a single file (multipart/form-data)

POST /v1/jobs — create a job

Request body:

FieldTypeRequiredDescription
sourceUrlsstring[]yesfile URLs to process
promptsstring[]nomodel instructions (only the first prompt runs); without prompts, the LLM is not called
neuralobjectyesmodel configuration (see “Connecting a model”)
ocrobjectyesOCR provider configuration (BYOK); always required (see “OCR configuration”)
extractionModeenumnoEXTRACTION_MODE_UNSPECIFIED | EXTRACTION_MODE_HYBRID | EXTRACTION_MODE_OCR_ALWAYS; omitted/UNSPECIFIED inherits the account default (itself defaulting to HYBRID)
responseSchemastringnooptional 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)
mergeobjectnooptional 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
consensusobjectnooptional 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
titlestringnoarbitrary job name
metadatamap<string,string>noarbitrary string key-value pairs
webhookUrlstringnoabsolute http/https endpoint to notify on job completion (see “Webhooks”)
webhookSecretstringnooptional HMAC secret for signing webhook requests; accepted as input only, never returned in responses
idempotencyKeystringnoclient 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):

FieldTypeRequiredDescription
ocr.providerenumyesone of NEURAL_CLIENT_TYPE_MISTRAL, _OPENAI, _CLAUDE, _DEEPSEEK, _GROK, _TOGETHER, _OPENROUTER, _XIAOMI
ocr.modelstringyesthe provider’s model id, e.g. mistral-ocr-latest (Mistral) or the chosen provider’s vision model
ocr.providerKeystringyesBYOK 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.

FieldTypeRequiredDescription
merge.enabledboolnoturns server-side merging on for this job
merge.scopeenumnoMERGE_SCOPE_UNSPECIFIED | MERGE_SCOPE_FILE (default) | MERGE_SCOPE_JOB; one merged[] entry per file, or one across all files
merge.conflictPolicyenumnoMERGE_CONFLICT_POLICY_UNSPECIFIED | MERGE_CONFLICT_POLICY_FIRST_NON_NULL (default) | MERGE_CONFLICT_POLICY_MAJORITY
merge.dedupeBystring[]noJSON 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.formatenumnoMERGE_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.

FieldTypeRequiredDescription
consensus.modeenumnoCONSENSUS_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, .xmlxml, .txtplain, 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:

FieldTypeDescription
jobId / filestringjob id / file URL
statusenumJOB_OCR_STATUS_PENDING / _SKIPPED / _DONE / _FAILED
contentstringrecognized text in the outputFormat format
outputFormatstringformat of content: markdown / html / xml / plain. Drives the structure-aware chunking at the LLM stage
modelstringOCR method/engine (e.g. pdf_fitz)
request / rawDatastringdebug: the OCR request and raw response
errorstringstage error (empty on success)
durationstringduration, ns (a number as a string — protojson returns int64 as a string)
created / updatedstringRFC3339

llm[] fields:

FieldTypeDescription
jobId / filestringjob id / file URL
promptIndexintprompt index (currently always 0)
chunkIndex / chunkTotalintchunk number / total chunks (if the text was split)
statusenumJOB_LLM_STATUS_PENDING / _SKIPPED / _DONE / _FAILED
skipReasonstringwhen SKIPPED: no_prompt / ocr_failed / no_ocr_content / empty_ocr_content
contentstringmodel answer (text as-is)
modelstringthe model string from the request
request / rawDatastringdebug
errorstringstage error (e.g. provider error (category=…, status=400))
durationstringduration, ns (a number as a string — protojson returns int64 as a string)
created / updatedstringRFC3339
schemaValidboolpresent only when the job set responseSchema and the file wasn’t split (chunkTotal = 1): whether content conforms to the schema
schemaErrorsstring[]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):

FieldTypeDescription
kintruns requested (2 / 3 / 5)
runsVotableintruns that produced a parseable, in-budget JSON answer and took part in the vote
minAgreementnumberlowest 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”
incompletebooltrue when runsVotable < k, or the vote degraded
disagreementsarrayfields 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)
disagreementsDroppedintdisagreement entries clamped off the row (count only; ≤100 entries kept)

merged[] fields (present only when the job set merge.enabled):

FieldTypeDescription
filestringfile URL this merged answer covers; empty ("") at scope=job
formatstringformat of content: json / xml / html / markdown / text
contentstringthe merged whole-document (or whole-file) answer
schemaValidboolpresent only when the job set responseSchema: whether the merged content conforms to it
conflictsarrayfields 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)
dedupeRemovedTechnicalintrecords removed by automatic boundary-overlap dedup
dedupeRemovedContentintrecords removed by your dedupeBy fields
mergeIncompletebooltrue when at least one chunk couldn’t be safely merged and was appended as-is instead
incompleteChunksarraythe 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.