Traitement de documents (Job API)
Cycle de vie d’un 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 — le job a été créé et mis en file d’attente.
- JOB_STATUS_FILE_PROCESSING — les fichiers sont téléchargés, les archives décompressées et les formats normalisés vers une forme traitable. Peut passer directement à
JOB_STATUS_FAILEDsi toutes les sources sont inaccessibles ou si une limite de fichiers est dépassée (la raison figure dans le champerrordu job). - JOB_STATUS_OCR — reconnaissance de texte pour chaque fichier.
- JOB_STATUS_LLM — le texte reconnu est envoyé au modèle avec vos prompts.
- JOB_STATUS_COMPLETE — aucune erreur aux étapes OCR ou LLM.
- JOB_STATUS_PARTIAL — au moins une réponse de modèle (LLM) réussie, mais aussi au moins une erreur à l’étape OCR ou LLM (vérifiez les erreurs au niveau des fichiers dans le résultat).
- JOB_STATUS_FAILED — des erreurs ont empêché tout fichier d’atteindre une réponse de modèle réussie : soit un échec lors du téléchargement/de la décompression des fichiers (la raison figure dans le champ
errorau niveau du job ;ocr[]/llm[]sont vides), soit aucun fichier n’a abouti à un résultat réussi à l’étape OCR ou LLM.
Le traitement est asynchrone : interrogez le statut du job via GET /v1/jobs/{id} jusqu’à ce que le job atteigne un statut terminal.
Envoi de fichiers
Vous pouvez indiquer la source d’un job de deux façons :
- URL publique — transmettez l’URL dans
sourceUrlslors de la création du job. hotdoc télécharge le fichier (délai de téléchargement : 30 s, jusqu’à 3 redirections). - Envoi direct — envoyez le fichier, puis utilisez l’URL renvoyée dans
sourceUrls:POST /v1/jobs/upload(multipart/form-data) — envoie un seul fichier via HTTP. C’est un endpoint HTTP autonome (pas grpc-gateway). La réponse est en JSON au format snake_case :{"url": "…", "name": "…", "size_bytes": 12345}. Le corps d’erreur de cet endpoint est{"code": <int>, "message": "…"}, sans tableaudetails. La limite de taille est définie par la configuration (grpc.maxRecvMsgBytes; 20 MiB dans le déploiement actuel), et non par une valeur par défaut du code.- méthode gRPC
Upload(client-streaming) — un envoi en flux (limite par message : 20 MiB).
Prompts et extraction de données
L’extraction de données repose sur des prompts textuels, et non sur un schéma. Dans le champ prompts, vous transmettez un tableau d’instructions. À l’étape LLM, le texte reconnu de chaque fichier est récupéré, découpé en chunks si nécessaire, et envoyé au modèle avec votre prompt. La réponse du modèle est renvoyée sous forme de texte par fichier (et par chunk, si le fichier a été découpé).
Le découpage en chunks est conscient de la structure : le texte est coupé le long des frontières de la structure de son format (Markdown, HTML, XML ou plain) — les tableaux ne sont pas déchirés au milieu d’une ligne (et si un tableau ne tient pas en entier, sa ligne d’en-tête est répétée dans chaque chunk), et le contexte des titres de section est préservé. La taille des chunks en tokens est définie par neural.chunkBudgetTokens (voir « Connexion d’un modèle ») ; si elle n’est pas définie, la valeur par défaut conservatrice du service s’applique. Chaque chunk est un appel de modèle distinct avec une copie complète du prompt et une ligne llm[] distincte (chunkIndex / chunkTotal). Le réassemblage de la réponse à partir des chunks dans l’ordre de chunkIndex se fait de votre côté — le service ne fusionne pas les chunks en un seul résultat.
Pour obtenir des données structurées, demandez-le directement dans le prompt — par exemple : « Return the result as JSON with the following fields: … ». Votre prompt et le modèle que vous avez choisi déterminent la validité et la forme du JSON ; hotdoc n’impose ni ne valide aucun schéma.
Conseils pour les prompts :
- listez explicitement et sans ambiguïté les champs dont vous avez besoin ;
- précisez le format de sortie directement dans le texte du prompt ;
- gardez à l’esprit la limite de taille du prompt — 64 KiB (voir « Limites »).
Remarque. Les marqueurs <…> dans les modèles ci-dessous indiquent les emplacements à compléter : hotdoc ne les remplace pas automatiquement — le prompt est envoyé au modèle exactement tel qu’il est écrit. Votre prompt et le modèle que vous choisissez déterminent la structure de sortie ; il n’y a aucune validation de schéma côté serveur.
La qualité de vos prompts détermine vos résultats. La qualité de l’extraction dépend autant de votre prompt que du modèle choisi — souvent davantage. Un prompt vague produit une sortie vague même sur un modèle haut de gamme, tandis qu’un prompt précis et bien structuré donne des résultats fiables même avec des modèles plus petits et moins coûteux. Traitez le modèle ci-dessous comme un point de départ, et non comme un prompt fini : prenez-le, décrivez votre type de document, votre tâche et la sortie exacte dont vous avez besoin, puis demandez à un modèle performant de le transformer en un prompt adapté à votre cas — en conservant cette structure tout en affinant les règles, les cas limites et la validation de la sortie pour vos données. Le méta-prompt prévu à cet effet se trouve à la fin de cette section.
Exemple : extraction des champs d’une facture / commande / reçu
Texte 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.
Enveloppe JSON (principale — sur le modèle Xiaomi mimo-v2-flash vérifié) :
{
"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"
}
}
En-tête : Authorization: Bearer <YOUR_HOTDOC_KEY>.
| Champ de requête | De quoi il s’agit | « Variable » |
|---|---|---|
Authorization | votre clé API hotdoc | clé d’accès API |
sourceUrls[] | URL des fichiers | liens des documents |
prompts[0] | le modèle entier sous forme d’une seule chaîne | prompt structuré |
neural.type / neural.model | fournisseur et modèle | modèle |
neural.apiKey | clé du fournisseur (BYOK) | clé du modèle |
neural.reasoningEffort | facultatif minimal/low/medium/high | profondeur de raisonnement |
ocr.provider | enum du fournisseur OCR (par ex. NEURAL_CLIENT_TYPE_MISTRAL) | fournisseur OCR |
ocr.model | identifiant du modèle OCR ; requis | modèle OCR |
ocr.providerKey | clé du fournisseur pour l’OCR (BYOK) ; acceptée uniquement en entrée, jamais renvoyée | clé OCR |
Autres exemples (en bref). Même mécanique — seuls le texte du prompt et la forme attendue de la réponse dans llm[].content diffèrent :
- Classification. Prompt : « Determine the document type: invoice / contract / receipt / letter / other. Return a single word from the list, with no explanation. » Réponse : un seul mot (par ex.
contract). - Conditions contractuelles. Prompt : « Extract: parties, subject, amount, term, and termination conditions. Return JSON matching the schema {parties[], subject, amount, term, termination}. Field not found → null. » Réponse : JSON conforme au schéma.
- Résumé. Prompt : « Summarize the document in 3–5 sentences. No bullet lists. » Réponse : texte en prose.
Construisez votre propre prompt (méta-prompt)
Le moyen le plus rapide d’obtenir un prompt de haute qualité est de le faire rédiger par un modèle performant. Confiez-lui le méta-prompt ci-dessous : collez-y notre exemple comme structure de référence, la forme de sortie dont vous avez besoin (JSON/CSV/Markdown) et une description de votre contexte et de votre tâche — et vous récupérez un prompt hotdoc prêt à l’emploi. Remplissez les blocs entre crochets ; le modèle s’occupe du reste.
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.
Connexion d’un modèle (BYOK)
L’étape LLM s’exécute sur votre clé de fournisseur. La configuration est transmise dans l’objet neural lors de la création d’un job :
| Champ | Requis | Description |
|---|---|---|
type | oui | fournisseur (voir la liste ci-dessous) |
model | oui | identifiant du modèle ; transmis tel quel au fournisseur |
apiKey | oui | votre clé de fournisseur ; acceptée uniquement en entrée, jamais renvoyée dans les réponses |
reasoningEffort | non | indication de profondeur de raisonnement ; valeurs autorisées : minimal, low, medium, high (vide = désactivé). Une valeur invalide → erreur 400. Sa prise en compte dépend du fournisseur/modèle. |
chunkBudgetTokens | non | budget de tokens pour un seul appel de modèle : couvre à la fois le prompt et le texte du document dans un même chunk. 0/non défini → la valeur par défaut conservatrice du service. Plage : 16000–2000000 ; une valeur hors plage → 400. Réglez-le sur la fenêtre de contexte de votre modèle — vous seul la connaissez. La réponse renvoie toujours le budget effectif réel (y compris lorsque vous vous reposez sur la valeur par défaut) : le service réserve une petite marge pour les tokens du chat-template, de sorte que la valeur renvoyée est légèrement inférieure à celle que vous avez définie. |
Fournisseurs pris en charge :
| Fournisseur | Valeur 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 |
Via NEURAL_CLIENT_TYPE_OPENROUTER, vous accédez à des modèles de nombreux fournisseurs qui ne disposent pas d’une intégration directe.
Vous payez directement le fournisseur à son tarif — hotdoc n’ajoute aucune marge sur les tokens ni sur l’OCR.
Réessais idempotents
Pour réessayer la création d’un job en toute sécurité, générez un seul idempotencyKey (un UUID) et réutilisez-le
sur tous les réessais de la même requête :
POST /v1/jobs
{ "sourceUrls": ["..."], "ocr": { ... }, "neural": { ... }, "idempotencyKey": "3f1c…" }
Réessayer avec la même clé et des paramètres identiques renvoie le job d’origine ;
modifier un paramètre quelconque sous la même clé renvoie 400. Utilisez une nouvelle clé pour un
job véritablement nouveau.
Webhooks
Les webhooks vous évitent l’interrogation (polling) : le service enverra un POST à votre endpoint une fois le job terminé. C’est entièrement facultatif — si vous ne renseignez pas les champs, le comportement de l’API reste inchangé.
Configuration
Lors de la création d’un job (POST /v1/jobs), transmettez l’un de ces champs facultatifs, ou les deux :
| Champ | Type | Description |
|---|---|---|
webhookUrl | string | URL absolue de votre endpoint (http:// ou https://). Acceptée uniquement en entrée — jamais renvoyée dans les réponses. |
webhookSecret | string | Secret HMAC de signature facultatif. Accepté uniquement en entrée — jamais renvoyé dans les réponses ; stocké chiffré au repos et supprimé avec le job (voir « Sécurité et données »). |
Exemple :
{
"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"
}
Quand le webhook se déclenche
Une fois par job — au premier passage à un statut terminal (JOB_STATUS_COMPLETE, JOB_STATUS_PARTIAL ou JOB_STATUS_FAILED). La livraison elle-même est at-least-once (des doublons sont possibles en cas d’échec) ; voir « Garanties de livraison ». Le webhook ne transporte pas le résultat du traitement ; il signale uniquement la fin. Récupérez le résultat complet avec le GET /v1/jobs/{id}/result habituel.
Corps de la requête
Le service envoie un POST à votre webhookUrl avec Content-Type: application/json et un corps JSON :
{
"job_id": "15b07304-...",
"account_id": "a1b2c3d4-...",
"status": "complete",
"finished_at": "2026-06-24T12:34:56Z"
}
| Champ | Type | Description |
|---|---|---|
job_id | string | UUID du job |
account_id | string (identifiant de compte) | Identifiant de compte |
status | string | L’un de : complete, partial, failed |
finished_at | string | Heure de fin du job au format RFC3339 (UTC) |
Vérification de la signature
Lorsque webhookSecret est défini, chaque requête inclut deux en-têtes supplémentaires :
| En-tête | Exemple de valeur | Description |
|---|---|---|
X-Hotdoc-Timestamp | 1750765200 | Heure Unix de la livraison (secondes) |
X-Hotdoc-Signature | sha256=a3f4... | Signature HMAC-SHA256 |
Algorithme de signature :
signature = "sha256=" + hex( hmac_sha256(secret, "<timestamp>.<body>") )
où <timestamp> est la forme chaîne de l’heure Unix issue de X-Hotdoc-Timestamp, <body> est le corps brut de la requête (les octets tels que reçus), et . est le séparateur. hex est en minuscules ; secret est utilisé comme octets UTF-8.
Comment vérifier de votre côté :
- Extrayez la valeur de
X-Hotdoc-Timestamp. - Calculez
hmac_sha256(secret, "<X-Hotdoc-Timestamp value from step 1>.<raw request body>"), encodez en hex minuscule et préfixez parsha256=. - Comparez-la à
X-Hotdoc-Signatureà l’aide d’une comparaison à temps constant (hmac.Equal/crypto/subtle.ConstantTimeCompareou équivalent). - Rejetez la requête si le timestamp est trop ancien (tolérance recommandée : 5 minutes).
Si webhookSecret n’est pas défini, les en-têtes X-Hotdoc-Timestamp et X-Hotdoc-Signature ne sont pas envoyés.
Politique de réessai
Si votre endpoint est injoignable ou renvoie une erreur, le service réessaie la livraison selon le calendrier suivant :
| Tentative | Délai avant la suivante |
|---|---|
| 1 → 2 | 1 minute |
| 2 → 3 | 5 minutes |
| 3 → 4 | 15 minutes |
| 4 → 5 | 30 minutes |
| 5 → 6 | 30 minutes |
| 6 | — (finale ; la livraison est ensuite marquée comme échouée) |
Total : jusqu’à 6 tentatives.
Les erreurs permanentes (4xx autres que 408/429, URL inutilisable, blocage SSRF) ne sont pas réessayées : la livraison est immédiatement marquée comme échouée. Une réponse 2xx est considérée comme un succès.
Garanties de livraison
La livraison est at-least-once : dans la plupart des cas votre endpoint reçoit exactement un appel, mais les réessais peuvent provoquer une livraison en double en cas d’échec. Dédupliquez les événements de votre côté à l’aide de job_id.