Procesamiento de documentos (Job API)

Ciclo de vida de una tarea

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 — la tarea se ha creado y puesto en cola.
  • JOB_STATUS_FILE_PROCESSING — se descargan los archivos, se descomprimen los comprimidos y se normalizan los formatos a una forma procesable. Puede pasar directamente a JOB_STATUS_FAILED si ninguno de los orígenes es accesible o se supera un límite de archivos (el motivo, en el campo error de la tarea).
  • JOB_STATUS_OCR — reconocimiento de texto de cada archivo.
  • JOB_STATUS_LLM — el texto reconocido se envía al modelo con sus prompts.
  • JOB_STATUS_COMPLETE — sin errores en la etapa OCR ni LLM.
  • JOB_STATUS_PARTIAL — al menos una respuesta del modelo (LLM) correcta, pero también al menos un error en la etapa OCR o LLM (revise los errores a nivel de archivo en el resultado).
  • JOB_STATUS_FAILED — los errores impidieron que ningún archivo llegara a una respuesta correcta del modelo: o bien un fallo durante la descarga/descompresión de archivos (el motivo, en el campo error a nivel de tarea; ocr[]/llm[] quedan vacíos), o bien ningún archivo alcanzó un resultado correcto en la etapa OCR o LLM.

El procesamiento es asíncrono: consulte el estado de la tarea vía GET /v1/jobs/{id} hasta que alcance un estado terminal.

Subida de archivos

Puede especificar el origen de una tarea de dos maneras:

  1. URL pública — pase la URL en sourceUrls al crear la tarea. hotdoc descarga el archivo (timeout de descarga: 30 s, hasta 3 redirecciones).
  2. Subida directa — suba el archivo y luego use la URL devuelta en sourceUrls:
    • POST /v1/jobs/upload (multipart/form-data) — sube un único archivo por HTTP. Es un endpoint HTTP independiente (no grpc-gateway). La respuesta es JSON en snake_case: {"url": "…", "name": "…", "size_bytes": 12345}. El cuerpo de error de este endpoint es {"code": <int>, "message": "…"}, sin array details. El límite de tamaño lo fija la configuración (grpc.maxRecvMsgBytes; 20 MiB en el despliegue actual), no un valor por defecto del código.
    • método gRPC Upload (client-streaming) — una subida en streaming (límite por mensaje individual: 20 MiB).

Prompts y extracción de datos

La extracción de datos se rige por prompts de texto, no por un esquema. En el campo prompts pasa un array de instrucciones. En la etapa LLM, se toma el texto reconocido de cada archivo, se divide en fragmentos si es necesario y se envía al modelo junto con su prompt. La respuesta del modelo se devuelve como texto por archivo (y por fragmento, si el archivo se dividió).

La división en fragmentos es consciente de la estructura: el texto se corta por los límites de la estructura de su formato (Markdown, HTML, XML o plain) — las tablas no se rompen a mitad de fila (y, si una tabla no cabe entera, su fila de encabezado se repite en cada fragmento) y se preserva el contexto de los encabezados de sección. El tamaño del fragmento en tokens lo fija neural.chunkBudgetTokens (consulte «Conectar un modelo»); si no se especifica, se aplica el valor por defecto conservador del servicio. Cada fragmento es una llamada al modelo independiente con una copia completa del prompt y una fila llm[] aparte (chunkIndex / chunkTotal). La recomposición de la respuesta a partir de los fragmentos en orden de chunkIndex corre de su lado: el servicio no fusiona los fragmentos en un único resultado.

Para obtener datos estructurados, pídalos directamente en el prompt; por ejemplo: «Devuelve el resultado como JSON con los siguientes campos: …». Su prompt y el modelo que elija determinan la validez y la forma del JSON; hotdoc no impone ni valida ningún esquema.

Consejos para los prompts:

  • enumere de forma explícita e inequívoca los campos que necesita;
  • especifique el formato de salida directamente en el texto del prompt;
  • tenga en cuenta el límite de tamaño del prompt: 64 KiB (consulte «Límites»).

Nota. Los marcadores <…> de las plantillas siguientes señalan los espacios que debe rellenar: hotdoc no los sustituye automáticamente, el prompt se envía al modelo exactamente como está escrito. Su prompt y el modelo que elija determinan la estructura de la salida; no hay validación de esquema del lado del servidor.

La calidad del prompt determina sus resultados. Lo bien que funcione la extracción depende tanto de su prompt como del modelo que elija, a menudo más. Un prompt vago produce una salida vaga incluso en un modelo de primer nivel, mientras que un prompt preciso y bien estructurado obtiene resultados fiables incluso de modelos más pequeños y baratos. Trate la plantilla siguiente como un punto de partida, no como un prompt acabado: tómela, describa su tipo de documento, su tarea y la salida exacta que necesita, y luego pida a un modelo capaz que la convierta en un prompt adaptado a su caso, manteniendo esta estructura pero afinando las reglas, los casos límite y la validación de la salida para sus datos. El meta-prompt para ello está al final de esta sección.

Ejemplo: extracción de campos de factura / 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.

Envoltorio JSON (principal — sobre el 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"
  }
}

Cabecera: Authorization: Bearer <YOUR_HOTDOC_KEY>.

Campo de la solicitudQué es«Variable»
Authorizationsu API key de hotdocclave de acceso a la API
sourceUrls[]URL de archivosenlaces a documentos
prompts[0]toda la plantilla como una sola cadenaprompt estructurado
neural.type / neural.modelproveedor y modelomodelo
neural.apiKeyclave del proveedor (BYOK)clave del modelo
neural.reasoningEffortopcional minimal/low/medium/highprofundidad de razonamiento
ocr.providerenum del proveedor de OCR (p. ej. NEURAL_CLIENT_TYPE_MISTRAL)proveedor de OCR
ocr.modelidentificador del modelo de OCR; obligatoriomodelo de OCR
ocr.providerKeyclave del proveedor para OCR (BYOK); se acepta solo como entrada, nunca se devuelveclave de OCR

Más ejemplos (en breve). La misma mecánica: solo cambian el texto del prompt y la forma esperada de la respuesta en llm[].content:

  • Clasificación. Prompt: «Determine el tipo de documento: factura / contrato / recibo / carta / otro. Devuelve una sola palabra de la lista, sin explicación». Respuesta: una sola palabra (p. ej. contract).
  • Cláusulas del contrato. Prompt: «Extraiga: partes, objeto, importe, plazo y condiciones de rescisión. Devuelve JSON conforme al esquema {parties[], subject, amount, term, termination}. Campo no encontrado → null». Respuesta: JSON conforme al esquema.
  • Resumen. Prompt: «Resuma el documento en 3-5 frases. Sin listas de viñetas». Respuesta: texto en prosa.

Construya su propio prompt (meta-prompt)

La forma más rápida de obtener un prompt de alta calidad es que un modelo capaz lo escriba por usted. Entréguele el meta-prompt de abajo: pegue nuestro ejemplo como estructura de referencia, la forma de salida que necesita (JSON/CSV/Markdown) y una descripción de su contexto y su tarea, y obtiene de vuelta un prompt de hotdoc listo para usar. Rellene los bloques entre corchetes; el modelo se encarga del 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.

Conectar un modelo (BYOK)

La etapa LLM se ejecuta con la clave de su proveedor. La configuración se pasa en el objeto neural al crear una tarea:

CampoObligatorioDescripción
typeproveedor (consulte la lista de abajo)
modelidentificador del modelo; se pasa al proveedor tal cual
apiKeyla clave de su proveedor; se acepta solo como entrada, nunca se devuelve en las respuestas
reasoningEffortnoindicación de profundidad de razonamiento; valores permitidos: minimal, low, medium, high (vacío = desactivado). Un valor inválido → error 400. Que se respete o no depende del proveedor/modelo.
chunkBudgetTokensnopresupuesto de tokens para una sola llamada al modelo: cubre tanto el prompt como el texto del documento en un único fragmento. 0/sin especificar → el valor por defecto conservador del servicio. Rango: 160002000000; un valor fuera de rango → 400. Ajústelo a la ventana de contexto de su modelo, que solo usted conoce. La respuesta siempre devuelve el presupuesto efectivo real (incluso cuando depende del valor por defecto): el servicio reserva un pequeño margen para los tokens de la plantilla de chat, por lo que el valor devuelto es algo menor que el que fijó.

Proveedores admitidos:

Proveedorvalor 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

A través de NEURAL_CLIENT_TYPE_OPENROUTER obtiene modelos de muchos proveedores que no tienen una integración directa.

Paga directamente al proveedor a su tarifa: hotdoc no añade ningún recargo sobre los tokens ni el OCR.

Reintentos idempotentes

Para reintentar la creación de una tarea de forma segura, genere un único idempotencyKey (un UUID) y reutilícelo en los reintentos de la misma solicitud:

POST /v1/jobs
{ "sourceUrls": ["..."], "ocr": { ... }, "neural": { ... }, "idempotencyKey": "3f1c…" }

Reintentar con la misma clave y parámetros idénticos devuelve la tarea original; cambiar cualquier parámetro con la misma clave devuelve 400. Use una clave nueva para una tarea genuinamente nueva.

Webhooks

Los webhooks le permiten evitar el sondeo en bucle: el servicio enviará un POST a su endpoint en cuanto la tarea termine. Es totalmente opcional: si no define los campos, el comportamiento de la API no cambia.

Configuración

Al crear una tarea (POST /v1/jobs), pase uno o ambos campos opcionales:

CampoTipoDescripción
webhookUrlstringURL absoluta de su endpoint (http:// o https://). Se acepta solo como entrada, nunca se devuelve en las respuestas.
webhookSecretstringSecreto opcional para la firma HMAC. Se acepta solo como entrada, nunca se devuelve en las respuestas; se almacena cifrado y se elimina junto con la tarea (consulte «Seguridad y datos»).

Ejemplo:

{
  "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"
}

Cuándo se dispara el webhook

Una vez por tarea, en la primera transición a un estado terminal (JOB_STATUS_COMPLETE, JOB_STATUS_PARTIAL o JOB_STATUS_FAILED). La entrega en sí es at-least-once (es posible que se dupliquen en caso de fallo); consulte «Garantías de entrega». El webhook no transporta el resultado del procesamiento; solo señala la finalización. Obtenga el resultado completo con la habitual solicitud GET /v1/jobs/{id}/result.

Cuerpo de la solicitud

El servicio envía un POST a su webhookUrl con Content-Type: application/json y un cuerpo JSON:

{
  "job_id":      "15b07304-...",
  "account_id":  "a1b2c3d4-...",
  "status":      "complete",
  "finished_at": "2026-06-24T12:34:56Z"
}
CampoTipoDescripción
job_idstringUUID de la tarea
account_idstring (identificador de cuenta)Identificador de cuenta
statusstringUno de: complete, partial, failed
finished_atstringHora de finalización de la tarea en formato RFC3339 (UTC)

Verificación de la firma

Cuando webhookSecret está definido, cada solicitud incluye dos cabeceras adicionales:

CabeceraValor de ejemploDescripción
X-Hotdoc-Timestamp1750765200Hora Unix de la entrega (segundos)
X-Hotdoc-Signaturesha256=a3f4...Firma HMAC-SHA256

Algoritmo de firma:

signature = "sha256=" + hex( hmac_sha256(secret, "<timestamp>.<body>") )

donde <timestamp> es la representación en cadena de la hora Unix de X-Hotdoc-Timestamp, <body> es el cuerpo de la solicitud en bruto (bytes tal como se reciben) y . es el separador. hex va en minúsculas; secret se usa como bytes UTF-8.

Cómo verificarla de su lado:

  1. Extraiga el valor de X-Hotdoc-Timestamp.
  2. Calcule hmac_sha256(secret, "<X-Hotdoc-Timestamp value from step 1>.<raw request body>"), codifíquelo como hex en minúsculas y anteponga sha256=.
  3. Compárelo con X-Hotdoc-Signature mediante una comparación de tiempo constante (hmac.Equal / crypto/subtle.ConstantTimeCompare o equivalente).
  4. Rechace la solicitud si la marca de tiempo es demasiado antigua (tolerancia recomendada: 5 minutos).

Si webhookSecret no está definido, las cabeceras X-Hotdoc-Timestamp y X-Hotdoc-Signature no se envían.

Política de reintentos

Si su endpoint no es accesible o devuelve un error, el servicio reintenta la entrega según el siguiente calendario:

IntentoRetraso antes del siguiente
1 → 21 minuto
2 → 35 minutos
3 → 415 minutos
4 → 530 minutos
5 → 630 minutos
6— (final; tras él la entrega se marca como fallida)

Total: hasta 6 intentos.

Los errores permanentes (4xx salvo 408/429, URL inutilizable, bloqueo SSRF) no se reintentan: la entrega se marca como fallida de inmediato. Una respuesta 2xx se considera un éxito.

Garantías de entrega

La entrega es at-least-once: en la mayoría de los casos su endpoint recibe exactamente una llamada, pero los reintentos pueden provocar entregas duplicadas en caso de fallo. Deduplique los eventos de su lado usando job_id.