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”) |
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.
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.
Which model should you use? Compare intelligence, per-token price and providers side by side in the LLM comparison, then set the model accordingly.
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). 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 |
Note. A machine-readable spec (OpenAPI/Swagger) isn’t generated yet; this reference is maintained by hand. OpenAPI is planned as a separate task.
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.