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_FAILED se todos os sources forem inacessíveis ou se um limite de arquivos for excedido (o motivo fica no campo error do 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 error no 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:

  1. URL pública — passe a URL em sourceUrls ao criar o job. O hotdoc baixa o arquivo (timeout de download: 30 s, até 3 redirecionamentos).
  2. 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 array details. 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çãoO que é”Variável”
Authorizationsua chave de API do hotdocchave de acesso à API
sourceUrls[]URLs dos arquivoslinks dos documentos
prompts[0]o template inteiro como uma única stringprompt estruturado
neural.type / neural.modelprovedor e modelomodelo
neural.apiKeychave do provedor (BYOK)chave do modelo
neural.reasoningEffortopcional minimal/low/medium/highprofundidade de raciocínio
ocr.providerenum do provedor de OCR (por exemplo, NEURAL_CLIENT_TYPE_MISTRAL)provedor de OCR
ocr.modelidentificador do modelo de OCR; obrigatóriomodelo de OCR
ocr.providerKeychave do provedor para OCR (BYOK); aceita apenas como entrada, nunca retornadachave 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:

CampoObrigatórioDescrição
typesimprovedor (veja a lista abaixo)
modelsimidentificador do modelo; passado ao provedor exatamente como está
apiKeysimsua chave de provedor; aceita apenas como entrada, nunca retornada nas respostas
reasoningEffortnãodica 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.
chunkBudgetTokensnãoorç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: 160002000000; 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:

Provedorvalor de neural.type
OpenAINEURAL_CLIENT_TYPE_OPENAI
Anthropic (Claude)NEURAL_CLIENT_TYPE_CLAUDE
xAI (Grok)NEURAL_CLIENT_TYPE_GROK
TogetherNEURAL_CLIENT_TYPE_TOGETHER
DeepSeekNEURAL_CLIENT_TYPE_DEEPSEEK
XiaomiNEURAL_CLIENT_TYPE_XIAOMI
MistralNEURAL_CLIENT_TYPE_MISTRAL
OpenRouterNEURAL_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:

CampoTipoDescrição
webhookUrlstringURL absoluta do seu endpoint (http:// ou https://). Aceita apenas como entrada — nunca retornada nas respostas.
webhookSecretstringSegredo 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"
}
CampoTipoDescrição
job_idstringUUID do job
account_idstring (identificador da conta)Identificador da conta
statusstringUm de: complete, partial, failed
finished_atstringHora 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çalhoValor de exemploDescrição
X-Hotdoc-Timestamp1750765200Hora Unix da entrega (segundos)
X-Hotdoc-Signaturesha256=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:

  1. Extraia o valor de X-Hotdoc-Timestamp.
  2. Calcule hmac_sha256(secret, "<X-Hotdoc-Timestamp value from step 1>.<raw request body>"), codifique como hex em minúsculas e prefixe com sha256=.
  3. Compare-o com X-Hotdoc-Signature usando uma comparação de tempo constante (hmac.Equal / crypto/subtle.ConstantTimeCompare ou equivalente).
  4. 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:

TentativaAtraso até a próxima
1 → 21 minuto
2 → 35 minutos
3 → 415 minutos
4 → 530 minutos
5 → 630 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.