Processamento de documentos (Job API)
Ciclo de vida do job
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 — o job foi criado e colocado na fila.
- JOB_STATUS_FILE_PROCESSING — os arquivos estão sendo baixados, os arquivos compactados descompactados e os formatos normalizados para uma forma processável. Pode transicionar diretamente para
JOB_STATUS_FAILEDse todos os sources forem inacessíveis ou se um limite de arquivos for excedido (o motivo fica no campoerrordo job). - JOB_STATUS_OCR — reconhecimento de texto de cada arquivo.
- JOB_STATUS_LLM — o texto reconhecido é enviado ao modelo com seus prompts.
- JOB_STATUS_COMPLETE — sem erros no estágio de OCR ou de LLM.
- JOB_STATUS_PARTIAL — ao menos uma resposta bem-sucedida do modelo (LLM), mas também ao menos um erro no estágio de OCR ou de LLM (verifique os erros por arquivo no resultado).
- JOB_STATUS_FAILED — erros impediram que qualquer arquivo chegasse a uma resposta bem-sucedida do modelo: ou uma falha durante o download/descompactação dos arquivos (o motivo fica no campo
errorno nível do job;ocr[]/llm[]ficam vazios), ou nenhum arquivo chegou a um resultado bem-sucedido no estágio de OCR ou de LLM.
O processamento é assíncrono: consulte o status do job via GET /v1/jobs/{id} até o job atingir um status terminal.
Envio de arquivos
Você pode especificar o source de um job de duas formas:
- URL pública — passe a URL em
sourceUrlsao criar o job. O hotdoc baixa o arquivo (timeout de download: 30 s, até 3 redirecionamentos). - Upload direto — envie o arquivo e então use a URL retornada em
sourceUrls:POST /v1/jobs/upload(multipart/form-data) — envia um único arquivo por HTTP. Este é um endpoint HTTP autônomo (não é grpc-gateway). A resposta é JSON em snake_case:{"url": "…", "name": "…", "size_bytes": 12345}. O corpo de erro deste endpoint é{"code": <int>, "message": "…"}, sem o arraydetails. O limite de tamanho é definido pela config (grpc.maxRecvMsgBytes; 20 MiB no deployment atual), e não por um padrão de código.- método gRPC
Upload(client-streaming) — um upload em streaming (limite de mensagem única: 20 MiB).
Prompts e extração de dados
A extração de dados é guiada por prompts de texto, não por um schema. No campo prompts você passa um array de instruções. No estágio de LLM, o texto reconhecido de cada arquivo é pego, dividido em chunks se necessário, e enviado ao modelo junto com seu prompt. A resposta do modelo é retornada como texto por arquivo (e por chunk, se o arquivo foi dividido).
A divisão em chunks é consciente da estrutura: o texto é cortado ao longo dos limites da estrutura do seu formato (Markdown, HTML, XML ou plain) — as tabelas não são rasgadas no meio de uma linha (e, se uma tabela não couber inteira, a sua linha de cabeçalho é repetida em cada chunk), e o contexto dos títulos de seção é preservado. O tamanho do chunk em tokens é definido por neural.chunkBudgetTokens (veja “Conectando um modelo”); se não for definido, aplica-se o padrão conservador do serviço. Cada chunk é uma chamada separada ao modelo, com uma cópia completa do prompt e uma linha llm[] separada (chunkIndex / chunkTotal). A remontagem da resposta a partir dos chunks na ordem de chunkIndex é feita do seu lado — o serviço não mescla os chunks em um único resultado.
Para obter dados estruturados, peça-os diretamente no prompt — por exemplo: “Return the result as JSON with the following fields: …”. Seu prompt e o modelo escolhido determinam a validade e o formato do JSON; o hotdoc não impõe nem valida nenhum schema.
Dicas de prompt:
- liste de forma explícita e inequívoca os campos de que você precisa;
- especifique o formato de saída diretamente no texto do prompt;
- tenha em mente o limite de tamanho do prompt — 64 KiB (veja “Limites”).
Nota. Os marcadores <…> nos templates abaixo indicam os pontos para você preencher: o hotdoc não os substitui automaticamente — o prompt é enviado ao modelo exatamente como está escrito. Seu prompt e o modelo escolhido determinam a estrutura da saída; não há validação de schema no lado do servidor.
A qualidade do prompt determina seus resultados. O quão bem a extração funciona depende tanto do seu prompt quanto do modelo escolhido — muitas vezes mais. Um prompt vago produz uma saída vaga mesmo em um modelo de ponta, enquanto um prompt preciso e bem estruturado obtém resultados confiáveis até de modelos menores e mais baratos. Trate o template abaixo como um ponto de partida, não como um prompt finalizado: pegue-o, descreva o tipo do seu documento, sua tarefa e a saída exata de que precisa, e então peça a um modelo capaz que o transforme em um prompt sob medida para o seu caso — mantendo esta estrutura e, ao mesmo tempo, refinando as regras, os casos extremos e a validação da saída para os seus dados. O meta-prompt para isso está no fim desta seção.
Exemplo: extrair campos de nota fiscal / pedido / recibo
Texto de 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.
Envelope JSON (principal — no Xiaomi mimo-v2-flash verificado):
{
"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"
}
}
Cabeçalho: Authorization: Bearer <YOUR_HOTDOC_KEY>.
| Campo da requisição | O que é | ”Variável” |
|---|---|---|
Authorization | sua chave de API do hotdoc | chave de acesso à API |
sourceUrls[] | URLs dos arquivos | links dos documentos |
prompts[0] | o template inteiro como uma única string | prompt estruturado |
neural.type / neural.model | provedor e modelo | modelo |
neural.apiKey | chave do provedor (BYOK) | chave do modelo |
neural.reasoningEffort | opcional minimal/low/medium/high | profundidade de raciocínio |
ocr.provider | enum do provedor de OCR (por exemplo, NEURAL_CLIENT_TYPE_MISTRAL) | provedor de OCR |
ocr.model | identificador do modelo de OCR; obrigatório | modelo de OCR |
ocr.providerKey | chave do provedor para OCR (BYOK); aceita apenas como entrada, nunca retornada | chave de OCR |
Mais exemplos (em resumo). Mesma mecânica — só mudam o texto do prompt e o formato esperado da resposta em llm[].content:
- Classificação. Prompt: “Determine the document type: invoice / contract / receipt / letter / other. Return a single word from the list, with no explanation.” Resposta: uma única palavra (por exemplo,
contract). - Termos de contrato. Prompt: “Extract: parties, subject, amount, term, and termination conditions. Return JSON matching the schema {parties[], subject, amount, term, termination}. Field not found → null.” Resposta: JSON conforme o schema.
- Resumo. Prompt: “Summarize the document in 3–5 sentences. No bullet lists.” Resposta: texto corrido.
Crie seu próprio prompt (meta-prompt)
A forma mais rápida de obter um prompt de alta qualidade é fazer um modelo capaz escrevê-lo para você. Entregue-lhe o meta-prompt abaixo: cole nosso exemplo como estrutura de referência, o formato de saída de que você precisa (JSON/CSV/Markdown) e uma descrição do seu contexto e tarefa — e você recebe de volta um prompt do hotdoc pronto para uso. Preencha os blocos entre colchetes; o modelo cuida do resto.
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.
Conectando um modelo (BYOK)
O estágio de LLM roda com a sua chave de provedor. A configuração é passada no objeto neural ao criar um job:
| Campo | Obrigatório | Descrição |
|---|---|---|
type | sim | provedor (veja a lista abaixo) |
model | sim | identificador do modelo; passado ao provedor exatamente como está |
apiKey | sim | sua chave de provedor; aceita apenas como entrada, nunca retornada nas respostas |
reasoningEffort | não | dica de profundidade de raciocínio; valores permitidos: minimal, low, medium, high (vazio = desligado). Um valor inválido → erro 400. Se será respeitado depende do provedor/modelo. |
chunkBudgetTokens | não | orçamento de tokens para uma única chamada ao modelo: cobre tanto o prompt quanto o texto do documento em um chunk. 0/não definido → o padrão conservador do serviço. Faixa: 16000–2000000; um valor fora da faixa → 400. Defina-o como a janela de contexto do seu modelo — só você a conhece. A resposta sempre retorna o orçamento efetivo real (inclusive quando você confia no padrão): o serviço reserva uma pequena margem para os tokens do chat-template, então o valor retornado é ligeiramente menor do que o que você definiu. |
Provedores suportados:
| Provedor | valor de 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 |
Por meio de NEURAL_CLIENT_TYPE_OPENROUTER você acessa modelos de muitos fornecedores que não têm integração direta.
Você paga o provedor diretamente pela tarifa dele — o hotdoc não adiciona markup sobre tokens ou OCR.
Retentativas idempotentes
Para repetir a criação de um job com segurança, gere um único idempotencyKey (um UUID) e reutilize-o
nas repetições da mesma requisição:
POST /v1/jobs
{ "sourceUrls": ["..."], "ocr": { ... }, "neural": { ... }, "idempotencyKey": "3f1c…" }
Repetir com a mesma chave e parâmetros idênticos retorna o job original;
alterar qualquer parâmetro sob a mesma chave retorna 400. Use uma nova chave para um
job genuinamente novo.
Webhooks
Os webhooks permitem que você evite o polling: o serviço fará um POST no seu endpoint assim que o job terminar. Isso é totalmente opcional — se você não definir os campos, o comportamento da API não muda.
Configuração
Ao criar um job (POST /v1/jobs), passe um ou ambos os campos opcionais:
| Campo | Tipo | Descrição |
|---|---|---|
webhookUrl | string | URL absoluta do seu endpoint (http:// ou https://). Aceita apenas como entrada — nunca retornada nas respostas. |
webhookSecret | string | Segredo opcional de assinatura HMAC. Aceito apenas como entrada — nunca retornado nas respostas; armazenado criptografado em repouso e excluído junto com o job (veja “Segurança e dados”). |
Exemplo:
{
"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"
}
Quando o webhook dispara
Uma vez por job — na primeira transição para um status terminal (JOB_STATUS_COMPLETE, JOB_STATUS_PARTIAL ou JOB_STATUS_FAILED). A entrega em si é at-least-once (duplicatas são possíveis em caso de falha); veja “Garantias de entrega”. O webhook não carrega o resultado do processamento; ele apenas sinaliza a conclusão. Busque o resultado completo com o habitual GET /v1/jobs/{id}/result.
Corpo da requisição
O serviço envia um POST para o seu webhookUrl com Content-Type: application/json e um corpo JSON:
{
"job_id": "15b07304-...",
"account_id": "a1b2c3d4-...",
"status": "complete",
"finished_at": "2026-06-24T12:34:56Z"
}
| Campo | Tipo | Descrição |
|---|---|---|
job_id | string | UUID do job |
account_id | string (identificador da conta) | Identificador da conta |
status | string | Um de: complete, partial, failed |
finished_at | string | Hora de conclusão do job no formato RFC3339 (UTC) |
Verificação da assinatura
Quando webhookSecret está definido, toda requisição inclui dois cabeçalhos adicionais:
| Cabeçalho | Valor de exemplo | Descrição |
|---|---|---|
X-Hotdoc-Timestamp | 1750765200 | Hora Unix da entrega (segundos) |
X-Hotdoc-Signature | sha256=a3f4... | Assinatura HMAC-SHA256 |
Algoritmo de assinatura:
signature = "sha256=" + hex( hmac_sha256(secret, "<timestamp>.<body>") )
onde <timestamp> é a forma em string da hora Unix do X-Hotdoc-Timestamp, <body> é o corpo bruto da requisição (bytes como recebidos) e . é o separador. hex é em minúsculas; secret é usado como bytes UTF-8.
Como verificar do seu lado:
- Extraia o valor de
X-Hotdoc-Timestamp. - Calcule
hmac_sha256(secret, "<X-Hotdoc-Timestamp value from step 1>.<raw request body>"), codifique como hex em minúsculas e prefixe comsha256=. - Compare-o com
X-Hotdoc-Signatureusando uma comparação de tempo constante (hmac.Equal/crypto/subtle.ConstantTimeCompareou equivalente). - Rejeite a requisição se o timestamp for antigo demais (tolerância recomendada: 5 minutos).
Se webhookSecret não estiver definido, os cabeçalhos X-Hotdoc-Timestamp e X-Hotdoc-Signature não são enviados.
Política de retentativas
Se o seu endpoint estiver inacessível ou retornar um erro, o serviço tenta entregar novamente conforme o seguinte cronograma:
| Tentativa | Atraso até a próxima |
|---|---|
| 1 → 2 | 1 minuto |
| 2 → 3 | 5 minutos |
| 3 → 4 | 15 minutos |
| 4 → 5 | 30 minutos |
| 5 → 6 | 30 minutos |
| 6 | — (final; a entrega é marcada como falha depois disso) |
Total: até 6 tentativas.
Erros permanentes (4xx que não sejam 408/429, URL inutilizável, bloqueio de SSRF) não são repetidos: a entrega é imediatamente marcada como falha. Uma resposta 2xx é considerada um sucesso.
Garantias de entrega
A entrega é at-least-once: na maioria dos casos o seu endpoint recebe exatamente uma chamada, mas as retentativas podem causar entrega duplicada em caso de falhas. Deduplique os eventos do seu lado usando job_id.