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”)
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.

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.

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, .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). 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

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.