Обработка документов (Job API)
Жизненный цикл задачи
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 — задача создана и поставлена в очередь.
- JOB_STATUS_FILE_PROCESSING — файлы скачиваются, архивы распаковываются, форматы приводятся к обрабатываемому виду. Может перейти напрямую в
JOB_STATUS_FAILED, если все источники недоступны или нарушен лимит файлов (причина — в полеerrorзадачи). - JOB_STATUS_OCR — распознавание текста по каждому файлу.
- JOB_STATUS_LLM — распознанный текст отправляется в модель с вашими промптами.
- JOB_STATUS_COMPLETE — нет ни одной ошибки на стадиях OCR и LLM.
- JOB_STATUS_PARTIAL — есть хотя бы один успешный ответ модели (LLM), но есть хотя бы одна ошибка на стадии OCR или LLM (смотрите ошибки на уровне файлов в результате).
- JOB_STATUS_FAILED — ошибки помешали хотя бы одному файлу получить успешный ответ модели: либо сбой на стадии загрузки/распаковки файлов (причина в поле
errorзадачи, массивыocr[]/llm[]пусты), либо ни один файл не дошёл до успешного результата OCR или LLM.
Обработка асинхронная: статус задачи опрашивается через GET /v1/jobs/{id} (polling), пока задача не дойдёт до терминального статуса.
Загрузка файлов
Источник для задачи можно задать двумя способами:
- Публичная ссылка — передайте URL в
sourceUrlsпри создании задачи. hotdoc скачает файл (таймаут скачивания — 30 с, до 3 редиректов). - Прямая загрузка — загрузите файл и используйте полученную ссылку в
sourceUrls:POST /v1/jobs/upload(multipart/form-data) — загрузка одного файла по HTTP. Это отдельный HTTP-эндпоинт (не grpc-gateway). Ответ — JSON в snake_case:{"url": "…", "name": "…", "size_bytes": 12345}. Тело ошибки этого эндпоинта —{"code": <int>, "message": "…"}без массиваdetails. Лимит размера задаётся конфигом (grpc.maxRecvMsgBytes; в текущем деплое — 20 MiB), не код-дефолт.- gRPC-метод
Upload(client-streaming) — потоковая загрузка (лимит одного сообщения — 20 MiB).
Промпты и извлечение данных
Извлечение данных задаётся текстовыми промптами, а не схемой. В поле prompts вы передаёте массив инструкций. На LLM-стадии для каждого файла берётся его распознанный текст, при необходимости делится на части (чанки) и отправляется в модель вместе с вашим промптом. Ответ модели возвращается как текст по каждому файлу (и по каждой части, если файл делился).
Деление на части структурно-осведомлённое: текст режется по границам структуры своего формата (Markdown, HTML, XML или plain) — таблицы не рвутся посреди строк (а если не помещаются целиком, заголовок таблицы повторяется в каждой части), сохраняется контекст заголовков-секций. Размер части в токенах задаётся полем neural.chunkBudgetTokens (см. «Подключение модели»); если оно не задано, берётся консервативный дефолт сервиса. Каждая часть — это отдельный вызов модели с полной копией промпта и отдельная строка в llm[] (поля chunkIndex / chunkTotal). Сборку ответа из частей в порядке возрастания chunkIndex выполняет ваша сторона — сервис не склеивает части в единый результат.
Чтобы получить структурированные данные, прямо попросите об этом в промпте — например: «Верни результат в формате JSON со следующими полями: …». Валидность и форму JSON определяют ваш промпт и выбранная модель; hotdoc не навязывает и не проверяет схему.
Рекомендации к промптам:
- перечисляйте нужные поля явно и однозначно;
- задавайте формат вывода прямо в тексте промпта;
- помните про лимит размера промпта — 64 KiB (см. «Лимиты»).
Примечание. <…> в шаблонах ниже — это места под вашу замену: hotdoc не подставляет их автоматически, промпт уходит в модель ровно как есть. Структуру вывода держат ваш промпт и выбранная модель — серверной валидации по схеме нет.
Качество промпта напрямую влияет на результат. Насколько хорошо отработает извлечение, зависит не только от выбранной модели, но и от промпта — зачастую даже сильнее. Расплывчатый промпт даёт расплывчатый результат даже на топовой модели; точный и хорошо структурированный — позволяет получать стабильный результат даже на небольших и недорогих моделях. Рассматривайте шаблон ниже как отправную точку, а не готовый промпт: возьмите его, опишите свой тип документа, свою задачу и нужный формат на выходе — и попросите сильную модель превратить его в промпт под ваш случай, сохранив структуру, но точно проработав правила, краевые случаи и валидацию вывода под ваши данные. Мета-промпт для этого — в конце раздела.
Пример: извлечение реквизитов из счёта/заказа/чека
Текст prompts[0]:
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-конверт (основной — на верифицированном 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"
}
}
Заголовок: Authorization: Bearer <YOUR_HOTDOC_KEY>.
| Поле запроса | Что это | «Переменная» |
|---|---|---|
Authorization | ваш API-ключ hotdoc | ключ доступа к API |
sourceUrls[] | ссылки на файлы | ссылки на документы |
prompts[0] | весь шаблон одной строкой | структурированный промпт |
neural.type / neural.model | провайдер и модель | модель |
neural.apiKey | ключ провайдера (BYOK) | ключ модели |
neural.reasoningEffort | опц. minimal/low/medium/high | глубина рассуждений |
ocr.provider | OCR-провайдер (enum, напр. NEURAL_CLIENT_TYPE_MISTRAL) | OCR-провайдер |
ocr.model | идентификатор OCR-модели; обязательное | OCR-модель |
ocr.providerKey | ключ провайдера для OCR (BYOK); принимается только на вход, в ответах не возвращается | ключ OCR |
Ещё примеры (компактно). Та же механика — отличается только текст промпта и ожидаемая форма ответа в llm[].content:
- Классификация. Промпт: «Determine the document type: invoice / contract / receipt / letter / other. Return a single word from the list, with no explanation.» Ответ: одно слово (напр.
contract). - Условия договора. Промпт: «Extract: parties, subject, amount, term, and termination conditions. Return JSON matching the schema {parties[], subject, amount, term, termination}. Field not found → null.» Ответ: JSON по схеме.
- Резюме. Промпт: «Summarize the document in 3–5 sentences. No bullet lists.» Ответ: связный текст.
Создание собственного промпта (мета-промпт)
Самый быстрый способ получить качественный промпт — поручить его сильной модели. Передайте ей мета-промпт ниже: вставьте наш пример как эталон структуры, желаемый формат вывода (JSON/CSV/Markdown), описание контекста и задачи — и получите готовый промпт для hotdoc. Заполните блоки в квадратных скобках, остальное модель доработает сама.
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.
Подключение модели (BYOK)
LLM-стадия выполняется на вашем ключе провайдера. Конфигурация передаётся в объекте neural при создании задачи:
| Поле | Обязательное | Описание |
|---|---|---|
type | да | провайдер (см. список ниже) |
model | да | идентификатор модели; передаётся провайдеру как есть |
apiKey | да | ваш ключ провайдера; принимается только на вход, в ответах не возвращается |
reasoningEffort | нет | подсказка по глубине рассуждений; допустимые значения: minimal, low, medium, high (пусто = выкл). Невалидное значение → ошибка 400. Учёт зависит от провайдера/модели. |
chunkBudgetTokens | нет | бюджет одного вызова модели в токенах: покрывает и промпт, и текст документа в одной части. 0/не задано → консервативный дефолт сервиса. Диапазон: 16000–2000000; значение вне диапазона → 400. Задавайте под контекстное окно вашей модели — его знаете только вы. В ответе всегда возвращается фактический эффективный бюджет (в том числе при использовании дефолта): сервис резервирует небольшой запас под служебные токены чата, поэтому возвращённое значение чуть меньше заданного. |
Поддерживаемые провайдеры:
| Провайдер | Значение neural.type |
|---|---|
| OpenAI | NEURAL_CLIENT_TYPE_OPENAI |
| Anthropic (Claude) | NEURAL_CLIENT_TYPE_CLAUDE |
| xAI (Grok) | NEURAL_CLIENT_TYPE_GROK |
| Together | NEURAL_CLIENT_TYPE_TOGETHER |
| DeepSeek | NEURAL_CLIENT_TYPE_DEEPSEEK |
| Xiaomi | NEURAL_CLIENT_TYPE_XIAOMI |
| Mistral | NEURAL_CLIENT_TYPE_MISTRAL |
| OpenRouter | NEURAL_CLIENT_TYPE_OPENROUTER |
Через NEURAL_CLIENT_TYPE_OPENROUTER доступны модели множества вендоров, у которых нет прямой интеграции.
Вы платите провайдеру напрямую по его тарифу — hotdoc не добавляет наценку на токены и OCR.
Идемпотентные повторы
Чтобы безопасно повторять создание задачи, сгенерируйте один idempotencyKey (UUID) и переиспользуйте его
в повторных попытках одного и того же запроса:
POST /v1/jobs
{ "sourceUrls": ["..."], "ocr": { ... }, "neural": { ... }, "idempotencyKey": "3f1c…" }
Повторный запрос с тем же ключом и идентичными параметрами вернёт исходную задачу;
смена любого параметра при том же ключе вернёт 400. Для нового задания используйте новый ключ.
Вебхуки
Вебхуки позволяют не опрашивать API в цикле: сервис сам уведомит ваш эндпоинт, когда задача завершится. Это опциональная функция — если поля не заданы, поведение API не меняется.
Настройка
При создании задачи (POST /v1/jobs) передайте одно или оба необязательных поля:
| Поле | Тип | Описание |
|---|---|---|
webhookUrl | string | Абсолютный URL вашего эндпоинта (http:// или https://). Принимается только на вход — в ответах не возвращается. |
webhookSecret | string | Опциональный секрет для HMAC-подписи. Принимается только на вход — в ответах не возвращается; хранится в зашифрованном виде и удаляется вместе с задачей (см. «Безопасность и данные»). |
Пример:
{
"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"
}
Когда срабатывает вебхук
Один раз на задачу — при первом переходе в терминальный статус (JOB_STATUS_COMPLETE, JOB_STATUS_PARTIAL или JOB_STATUS_FAILED). Сама доставка — at-least-once (возможны повторы при сбоях), см. «Гарантии доставки». Результат обработки вебхук не несёт — он лишь сигнализирует о завершении. Полный результат заберите обычным запросом GET /v1/jobs/{id}/result.
Тело запроса
Сервис отправляет POST на ваш webhookUrl с заголовком Content-Type: application/json и JSON-телом:
{
"job_id": "15b07304-...",
"account_id": "a1b2c3d4-...",
"status": "complete",
"finished_at": "2026-06-24T12:34:56Z"
}
| Поле | Тип | Описание |
|---|---|---|
job_id | string | UUID задачи |
account_id | string (идентификатор аккаунта) | Идентификатор аккаунта |
status | string | Один из: complete, partial, failed |
finished_at | string | Время завершения задачи в формате RFC3339 (UTC) |
Проверка подписи
Если webhookSecret задан, каждый запрос содержит два дополнительных заголовка:
| Заголовок | Пример значения | Описание |
|---|---|---|
X-Hotdoc-Timestamp | 1750765200 | Unix-время отправки (секунды) |
X-Hotdoc-Signature | sha256=a3f4... | HMAC-SHA256 подпись |
Алгоритм подписи:
signature = "sha256=" + hex( hmac_sha256(secret, "<timestamp>.<body>") )
где <timestamp> — строковое представление Unix-времени из X-Hotdoc-Timestamp, <body> — сырое тело запроса (байты как есть), точка — разделитель. hex — в нижнем регистре; secret используется как UTF-8-байты.
Как проверить на вашей стороне:
- Извлеките значение
X-Hotdoc-Timestamp. - Вычислите
hmac_sha256(secret, "<X-Hotdoc-Timestamp value from step 1>.<raw request body>"), возьмите hex в нижнем регистре и добавьте префиксsha256=. - Сравните с
X-Hotdoc-Signatureсравнением, не зависящим от времени выполнения (constant-time) (функции типаhmac.Equal/crypto/subtle.ConstantTimeCompare). - Отклоните запрос, если метка времени слишком старая (рекомендуемый допуск — 5 минут).
Если webhookSecret не задан, заголовки X-Hotdoc-Timestamp и X-Hotdoc-Signature не отправляются.
Политика повторов
При недоступности или ошибке вашего эндпоинта сервис повторяет доставку по расписанию:
| Попытка | Задержка перед следующей |
|---|---|
| 1 → 2 | 1 минута |
| 2 → 3 | 5 минут |
| 3 → 4 | 15 минут |
| 4 → 5 | 30 минут |
| 5 → 6 | 30 минут |
| 6 | — (финальная, после неё доставка помечается неудачной) |
Итого: максимум 6 попыток.
Постоянные ошибки (4xx кроме 408/429, недопустимый URL, SSRF-блокировка) не повторяются: доставка сразу помечается неудачной. Успешным считается ответ с кодом 2xx.
Гарантии доставки
Доставка — at-least-once: в большинстве случаев вебхук придёт ровно один раз, но при сбоях возможна повторная доставка. Дедуплицируйте события по job_id на своей стороне.