Document AI#
Document AI transforma PDF/JPEG/PNG/WebP privados em dados estruturados. Cada job fixa uma versão imutável de JSON Schema e de política de qualidade, processa o documento por página e expõe o resultado para polling e revisão. Confiança é metadado do modelo; aceitação é decisão de política — a API não promete precisão perfeita.
Todas as rotas usam /v1/document-ai e exigem JWT ou API key ctc_. O tenant
vem da empresa autenticada:
- omita
tenant_external_idpara usar o tenant padrão; - envie o identificador opaco do tenant na query, no JSON ou no multipart para operar outro tenant da mesma empresa;
- nunca envie
company_idou o id interno do tenant.
Leituras básicas aceitam qualquer papel autenticado. Resultado, preview, revisão,
segredo de callback e toda mutação exigem owner ou admin.
GETRotas e RBAC#
O inventário abaixo contém 24 combinações método+rota em 18 paths públicos únicos.
| Método e rota | Papel | Resposta/efeito |
|---|---|---|
GET /overview |
autenticado | métricas agregadas |
GET /jobs · GET /jobs/{jobId} |
autenticado | lista/detalhe do job |
POST /jobs |
owner/admin | upload multipart idempotente |
POST /jobs/{jobId}/cancel |
owner/admin | cancela |
POST /jobs/{jobId}/retry |
owner/admin | recoloca todas as páginas falhas e zera o orçamento de tentativas |
DELETE /jobs/{jobId} |
owner/admin | apaga objetos privados e o agregado |
GET /jobs/{jobId}/preview |
owner/admin | URL privada temporária |
GET /jobs/{jobId}/result |
owner/admin | resultado estruturado e auditoria |
GET /jobs/{jobId}/deliveries |
owner/admin | ledger content-free de callbacks/retries |
GET /schemas · GET /schemas/{schemaId} |
autenticado | lista/detalhe da versão atual |
POST /schemas · POST /schemas/{schemaId}/versions |
owner/admin | cria/append de versão |
DELETE /schemas/{schemaId} |
owner/admin | arquiva sem apagar versões |
GET /synthetic-template |
autenticado | exemplo sem dados reais |
GET /policies · GET /policies/{policyId} |
autenticado | lista/detalhe da versão atual |
POST /policies · POST /policies/{policyId}/versions |
owner/admin | cria/append de versão |
DELETE /policies/{policyId} |
owner/admin | arquiva sem apagar versões |
GET /reviews |
owner/admin | fila e histórico de revisão |
POST /reviews/{reviewId}/decisions |
owner/admin | decisão humana append-only |
GET /callback-secret |
owner/admin | segredo do tenant para verificar callbacks |
reviewId é atualmente o job_id. As quatro listas retornam arrays JSON nus,
sem paginação. Filtros disponíveis:
/jobs?status=NEEDS_REVIEW&external_reference=...;/schemas?include_archived=true;/policies?include_archived=true;- qualquer GET aceita
tenant_external_id=....
Os quatro POSTs de configuração (/schemas, /schemas/{schemaId}/versions,
/policies e /policies/{policyId}/versions) exigem Idempotency-Key:
primeira escrita 201, replay idêntico 200 e payload diferente com a mesma
chave 409 DOCUMENT_AI_IDEMPOTENCY_CONFLICT.
POSTCriar e versionar schema#
O dialecto é um subconjunto autocontido de JSON Schema draft 2020-12. A raiz
precisa ser type:"object" com additionalProperties:false e propriedades
escalares. $ref, objetos/arrays aninhados e keywords fora do subconjunto são
recusados.
curl -X POST https://agents-api.catcher.one/v1/document-ai/schemas \
-H "X-API-Key: $CATCHER_API_KEY" \
-H "Idempotency-Key: schema-ficha-clinica-v1" \
-H "Content-Type: application/json" \
-d '{
"tenant_external_id": "tenant_opaco_opcional",
"name": "ficha-clinica",
"description": "Ficha clínica estruturada",
"json_schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"patient_name": {
"type": ["string", "null"],
"description": "Nome visível no documento",
"x-risk": "SENSITIVE"
},
"cpf": {
"type": ["string", "null"],
"description": "CPF visível no documento",
"x-risk": "CRITICAL",
"x-value-type": "cpf"
},
"document_date": {
"type": ["string", "null"],
"format": "date",
"x-risk": "NORMAL"
}
},
"required": ["patient_name", "cpf", "document_date"]
}
}'
Cada propriedade aceita:
| Campo | Valores |
|---|---|
type |
string, number, integer ou boolean, com null opcional |
description |
texto opcional para orientar a extração |
x-risk |
NORMAL (default), SENSITIVE, CRITICAL |
x-value-type |
text, date, cpf, cnpj, phone, money, boolean |
format |
date seleciona normalização de data |
Também é possível enviar field_policies: {"cpf":{"risk":"CRITICAL"}}; o
servidor incorpora o risco no schema canônico.
Normalização de data — janela de ano aceita. Um campo date só produz valor
normalizado quando o ano cai entre 1900 e 2100. Fora disso o campo é tratado
como ausente (normalized_value: null) e vai para revisão, em vez de virar
valor extraído.
O motivo é prático: um modelo lendo 14/03/2026 sob decodificação restrita pode
emitir 1403-03-14 — uma data sintaticamente válida que não estava no
documento. Aceitá-la transformaria uma falha de leitura em dado. A janela é
larga de propósito (uma certidão antiga e um contrato de longo prazo passam);
ela só recusa anos que nenhum documento de negócio carrega.
Resposta 201:
{
"id": "uuid-do-schema",
"name": "ficha-clinica",
"version": 1,
"version_id": "uuid-da-versao",
"status": "ACTIVE",
"description": "Ficha clínica estruturada",
"fields": [
{
"name": "cpf",
"description": "CPF visível no documento",
"required": true,
"risk": "CRITICAL",
"value_type": "cpf",
"json_types": ["null", "string"],
"format": ""
}
],
"json_schema": {},
"checksum": "sha256-do-schema-canonico",
"created_at": "2026-07-27T20:30:00Z",
"idempotent_replay": false
}
Para evoluir, envie o mesmo body a
POST /schemas/{schemaId}/versions, com uma chave de idempotência própria.
name é necessário na criação; a versão identifica o recurso pelo schemaId.
O response traz novo version e version_id; a primeira execução retorna
201, e o replay idêntico retorna 200 com idempotent_replay:true. Jobs
existentes continuam presos à versão antiga. DELETE /schemas/{schemaId}
responde 204 e arquiva.
Limites do schema: 256 KiB, profundidade JSON 32 e nome de propriedade até 256 caracteres.
POSTCriar e versionar política#
curl -X POST https://agents-api.catcher.one/v1/document-ai/policies \
-H "X-API-Key: $CATCHER_API_KEY" \
-H "Idempotency-Key: policy-reliable-default-v1" \
-H "Content-Type: application/json" \
-d '{
"name": "reliable-default",
"profile": "RELIABLE",
"primary_model": "x-ai/grok-4.5",
"secondary_model": "qwen/qwen3.7-plus",
"confidence_threshold": 0.95,
"prompt_version": "document-extract-v1",
"prompt_template": "Extract only values visibly present in this document page.",
"routing_version": "openrouter-structured-v1",
"max_tokens": 4096,
"timeout_ms": 120000,
"model_allowlist_version": "document-models-v1",
"allowed_models": [
"qwen/qwen3.7-plus",
"x-ai/grok-4.5"
]
}'
Perfis:
| Perfil | Regra de aceitação |
|---|---|
DRAFT |
um modelo; tudo permanece não verificado e vai para revisão |
RELIABLE |
dois modelos diferentes; autoaceite só quando ambos leram, atingiram o threshold, não emitiram warnings e produziram o mesmo valor normalizado |
CRITICAL |
dois modelos + adjudicador; campo CRITICAL, warning, baixa confiança ou divergência remanescente exige humano |
confidence_threshold defaulta para 0.95; prompt_version para
document-extract-v1; routing_version para openrouter-structured-v1; e
model_allowlist_version para document-models-v1. prompt_template aceita
16..8.192 bytes; max_tokens, 256..16.384 (default 4.096); e timeout_ms,
1.000..120.000 (default 120.000).
Sem allowed_models, a lista default contém
google/gemini-3.1-pro-preview, qwen/qwen3.7-plus e x-ai/grok-4.5.
Quando enviada, todos os modelos escolhidos precisam pertencer a essa lista
versionada. O texto normalizado do prompt e a lista de modelos ficam preservados
na versão imutável; o view da API não os devolve e expõe prompt_checksum e
model_allowlist_version para auditoria.
{
"id": "uuid-da-politica",
"name": "reliable-default",
"version": 1,
"version_id": "uuid-da-versao",
"profile": "RELIABLE",
"models": ["x-ai/grok-4.5", "qwen/qwen3.7-plus"],
"primary_model": "x-ai/grok-4.5",
"secondary_model": "qwen/qwen3.7-plus",
"adjudicator_model": "",
"confidence_threshold": 0.95,
"prompt_version": "document-extract-v1",
"prompt_checksum": "sha256-do-prompt-normalizado",
"routing_version": "openrouter-structured-v1",
"max_tokens": 4096,
"timeout_ms": 120000,
"model_allowlist_version": "document-models-v1",
"requires_human_review": false,
"status": "ACTIVE",
"created_at": "2026-07-27T20:30:00Z",
"idempotent_replay": false
}
POST /policies/{policyId}/versions, com chave própria, acrescenta versão
imutável. Criar/versionar retorna 201 na primeira execução e 200 no replay
idêntico. DELETE /policies/{policyId} arquiva e responde 204.
POSTProcessar documento#
O upload usa multipart/form-data e exige Idempotency-Key:
curl -X POST https://agents-api.catcher.one/v1/document-ai/jobs \
-H "X-API-Key: $CATCHER_API_KEY" \
-H "Idempotency-Key: adria-documento-550e8400" \
-F "file=@documento.pdf;type=application/pdf" \
-F "tenant_external_id=tenant_opaco_opcional" \
-F "schema_version_id=uuid-da-versao-do-schema" \
-F "policy_version_id=uuid-da-versao-da-politica" \
-F "external_reference=adria:clinic:123:document:456" \
-F "callback_url=https://integrador.example.com/catcher/document-ai"
file, schema_version_id e policy_version_id são obrigatórios. Use os
version_id retornados pelas APIs de configuração, não o número version.
external_reference é correlação pesquisável; não é chave de idempotência.
callback_url é opcional e precisa ser HTTPS público.
Limites: 20 MiB por arquivo; PDF até 50 páginas; JPEG/PNG/WebP até 12.000 px por dimensão e 16 milhões de pixels. MIME declarado e detectado precisam concordar. ZIP, arquivo truncado/malformado e callback para rede privada falham antes de enfileirar. A inspeção ocorre num processo killable com limites de CPU, RSS, address space, wall clock e concorrência global/tenant; orientação, nitidez e ilegibilidade determinísticas ficam no estado da página.
Primeira resposta 201; replay idêntico 200:
{
"id": "uuid-do-job",
"status": "QUEUED",
"state": "QUEUED",
"filename": "private-document",
"schema": {
"id": "uuid-do-schema",
"version": 2,
"version_id": "uuid-da-versao-do-schema",
"checksum": "sha256-do-schema"
},
"policy": {
"id": "uuid-da-politica",
"version": 3,
"version_id": "uuid-da-versao-da-politica",
"prompt_version": "document-extract-v1",
"routing_version": "openrouter-structured-v1",
"prompt_checksum": "sha256-do-prompt-normalizado",
"max_tokens": 4096,
"timeout_ms": 120000
},
"quality_profile": "RELIABLE",
"external_reference": "adria:clinic:123:document:456",
"page_count": 2,
"processed_pages": 0,
"failed_pages": 0,
"progress_percent": 0,
"last_error_code": "",
"created_at": "2026-07-27T20:31:00Z",
"updated_at": "2026-07-27T20:31:00Z",
"retention_expires_at": "2026-08-26T20:31:00Z",
"links": {
"self": "/v1/document-ai/jobs/uuid-do-job",
"result": "/v1/document-ai/jobs/uuid-do-job/result"
},
"idempotent_replay": false
}
O filename real e a chave do objeto nunca são expostos.
GETPolling, estados e resultado#
Estados: UPLOADED, QUEUED, PROCESSING, NEEDS_REVIEW, COMPLETED,
FAILED, CANCELED. A criação normalmente já é observada como QUEUED.
CANCELED, COMPLETED e FAILED aceitam hard delete; um FAILED sempre pode
voltar a QUEUED via POST /jobs/{jobId}/retry.
curl -H "X-API-Key: $CATCHER_API_KEY" \
"https://agents-api.catcher.one/v1/document-ai/jobs/$JOB_ID"
curl -H "X-API-Key: $CATCHER_API_KEY" \
"https://agents-api.catcher.one/v1/document-ai/jobs/$JOB_ID/result"
Resultado:
{
"job_id": "uuid-do-job",
"state": "NEEDS_REVIEW",
"quality_profile": "RELIABLE",
"fields": {},
"pages": [
{
"page_number": 1,
"status": "DONE",
"attempt_count": 1,
"safe_to_retry": false,
"source_orientation": 1,
"sharpness": 184,
"unreadable": false,
"preprocess_warnings": [],
"fields": {
"cpf": {
"raw_value": "123.456.789-09",
"normalized_value": "12345678909",
"decision": "DIVERGENT",
"confidence": 0.81,
"needs_review": true,
"reason": "MODEL_DISAGREEMENT",
"warnings": ["MODEL_DISAGREEMENT"]
}
}
}
],
"summary": {
"total_fields": 3,
"review_fields": 1,
"candidate_count": 6,
"cost_usd": "0.017534",
"processing_ms": 49140,
"models": ["qwen/qwen3.7-plus", "x-ai/grok-4.5"],
"providers": ["openrouter"]
},
"audit_contract": {
"schema_version_id": "uuid-da-versao-do-schema",
"policy_version_id": "uuid-da-versao-da-politica",
"prompt_version": "document-extract-v1",
"routing_version": "openrouter-structured-v1",
"prompt_checksum": "sha256-do-prompt-normalizado",
"max_tokens": 4096,
"timeout_ms": 120000
}
}
Em documento de uma página, fields repete a única página. Em multipágina,
consuma pages[].fields. cost_usd aqui é string decimal de seis casas.
preprocess_warnings — o que a página tinha ANTES do modelo#
Cada pages[] traz sharpness, unreadable, preprocess_warnings e a
geometria do render (render_width, render_height, render_dpi) — sinais
medidos no preparo, antes de qualquer inferência. Servem para você explicar ao
seu usuário por que um documento voltou vazio, sem culpar o modelo.
Esses sinais são medidos sobre exatamente a imagem que o modelo lê, não sobre
um artefato paralelo. Um sharpness alto significa que a página que foi ao
modelo está nítida — nada mais é medido em outro lugar.
render_dpi é a resolução efetiva da página em dpi (0 quando a geometria
física é desconhecida — uma imagem enviada direto não tem tamanho físico). É o
sinal para recusar um documento antes de gastar a extração: abaixo de ~200
dpi, caligrafia começa a ficar ambígua.
PDF e imagem são o mesmo caminho#
Você pode enviar PDF ou imagem para o mesmo documento digitalizado e esperar o mesmo resultado. Toda página de PDF é rasterizada por nós na resolução que o documento carrega (limitada a 12.000 px por dimensão e 16 Mpx) e é esse render que vai ao modelo — nunca o PDF bruto. Não há parser de PDF de terceiros no meio, e portanto não há diferença de leitura, de custo ou de latência entre os dois transportes.
Você não precisa converter PDF para imagem antes de enviar. Se já tem a imagem original, mandá-la direto também é ótimo — é só um passo a menos.
| Warning | Significado | O que fazer |
|---|---|---|
blank_page |
a página é uniforme: não há nada nela. Vale para folha em branco e para o verso não digitalizado | não peça um scan melhor — peça a página certa |
low_sharpness |
a página tem conteúdo, mas está borrada/fora de foco | peça uma nova foto, com mais luz e sem tremer |
dimensions_too_small |
a página é pequena demais para leitura confiável | envie em resolução maior |
blank_page e low_sharpness costumavam vir juntos como um único
low_sharpness, o que induzia a pedir "uma foto melhor" de uma folha que
simplesmente estava vazia. Agora são distintos — e um blank_page aparece
junto com low_sharpness, porque uma página vazia também não tem nitidez.
Imagens com canal alfa (transparência) são achatadas sobre branco antes de ir ao modelo. Isso é deliberado: sem o achatamento, o transparente vira preto no provedor e o modelo descreve corretamente "imagem totalmente preta" — devolvendo todos os campos vazios com alta confiança e nenhuma explicação.
last_error_code — por que o job falhou#
Quando um job termina em FAILED, last_error_code (no job e em cada
pages[]) diz o que aconteceu — é um código operacional, não um error_code
HTTP. Ele existe para você distinguir "meu documento/config está errado" de "o
serviço falhou", sem abrir chamado.
last_error_code |
Significado | De quem é a ação |
|---|---|---|
PROVIDER_ACCOUNT_UNAVAILABLE |
a conta de provedor de modelos da plataforma não pôde atender; o provedor recusou antes de qualquer inferência (por isso cost_usd fica 0) |
nossa — não reenvie, não é o seu documento |
PROVIDER_MODEL_NOT_FOUND |
o slug de modelo fixado na sua policy não existe no provedor | sua — corrija a policy |
PROVIDER_CONTEXT_LENGTH |
a página excede a janela de contexto do modelo | sua — outro modelo ou documento menor |
PROVIDER_INVALID_REQUEST |
o provedor rejeitou a requisição | abra chamado com o job_id |
PROVIDER_SCHEMA_VIOLATION |
a saída do modelo não satisfez o seu JSON Schema | sua — revise o schema |
PROVIDER_MODEL_MISMATCH |
o provedor respondeu com um modelo diferente do pedido | nossa |
PROVIDER_RATE_LIMITED |
throttling do provedor | transitório — chame /retry |
PROVIDER_TIMEOUT |
a tentativa estourou o tempo da policy | transitório — chame /retry |
PROVIDER_UNAVAILABLE |
indisponibilidade genérica, sem classificação mais específica | transitório — chame /retry |
MODEL_ATTEMPT_LIMIT |
o orçamento durável de tentativas do job/tenant foi atingido | sua |
PAGE_ATTEMPTS_EXHAUSTED |
a página foi reivindicada o número máximo de vezes sem concluir. É o teto que garante que todo job chega a um estado terminal: em vez de ficar PROCESSING indefinidamente, o job vai a FAILED com este código |
nossa — abra chamado com o job_id |
PRIVATE_OBJECT_INVALID |
a página armazenada não pôde ser lida | nossa |
SCHEMA_VERSION_UNAVAILABLE · SCHEMA_VERSION_INVALID |
a versão de schema fixada no job sumiu ou não parseia | sua |
POLICY_VERSION_UNAVAILABLE |
a versão de policy fixada no job sumiu | sua |
Regras de consumo:
- Todo job chega a um estado terminal.
COMPLETED,NEEDS_REVIEW,FAILEDouCANCELED— nuncaPROCESSINGpara sempre. Uma página tem um teto de reivindicações; esgotado, o job vai aFAILEDcomPAGE_ATTEMPTS_EXHAUSTEDem vez de repetir indefinidamente. Você pode, portanto, tratar "sem estado terminal depois de muito tempo" como incidente nosso, e não como lentidão. - Trate a lista como aberta. Códigos novos podem aparecer; qualquer valor desconhecido deve cair no seu caminho genérico de falha, nunca quebrar o parser.
- Nunca deduza retryability do texto — use a operação
/retrye o camposafe_to_retryda página. /retryé sempre uma saída válida. Ele recoloca todas as páginas falhas do job — inclusive as comsafe_to_retry: false— e zera o orçamento de tentativas da página.safe_to_retrydiz se a NOSSA máquina pode retomar sozinha, e nunca impede a sua decisão explícita de tentar de novo. Tentativas de modelo já concluídas são reaproveitadas, então um/retrynão paga duas vezes pelo que já foi extraído — prefira/retrya reenviar o documento.- Os códigos
PROVIDER_*derivam do status HTTP do provedor (e, só em 400/422, de uma checagem de palavra-chave no erro dele para separar estouro de contexto de requisição malformada); nunca carregam bytes do documento, prompt, schema, prosa do provedor ou valor extraído. PROVIDER_ACCOUNT_UNAVAILABLEnão distingue "sem saldo" de "credencial revogada", e isso é deliberado: a credencial de extração é da plataforma, e o código é visível a qualquer conta autenticada. Para você a decisão é a mesma — é nosso, pare de reenviar, o documento não tem culpa.- Pode ser específico por formato.
PROVIDER_ACCOUNT_UNAVAILABLEnum PDF não significa que a extração inteira caiu: página PDF e página de imagem seguem caminhos distintos no provedor, e um pode estar indisponível enquanto o outro atende. Se você tem o documento em imagem (JPEG/PNG/WebP), reenviar nesse formato é um caminho legítimo — não é gambiarra, é outro transporte.
Saber ANTES de enviar: last_failure no overview#
GET /v1/document-ai/overview traz a falha terminal mais recente do seu
tenant, para você avisar seu operador antes de enfileirar outro documento:
{
"total_documents": 13,
"failed_documents": 1,
"last_failure": { "code": "PROVIDER_ACCOUNT_UNAVAILABLE", "at": "2026-07-28T12:00:00Z" }
}
last_failure é null quando o tenant nunca falhou — o campo está sempre
presente, é seguro ler sem checar. Regra de consumo sugerida: se code é
PROVIDER_ACCOUNT_UNAVAILABLE e at é recente, avise o operador em vez de
aceitar o upload; para os demais códigos, siga normalmente (a falha era daquele
documento, não do serviço).
Não expomos saldo do provedor nem um "health" global, por decisão: o saldo é
estado financeiro nosso e não é seu para ler, e um health global honesto ou
custaria uma chamada paga a cada consulta ou serviria cache — que mente
exatamente no minuto em que importa. last_failure é observação, não previsão.
Preview autorizado:
GET /v1/document-ai/jobs/{jobId}/preview
GET /v1/document-ai/jobs/{jobId}/preview?page=2
Resposta: { "url":"https://...", "expires_in_seconds":300, "content_type":"image/jpeg" }.
O content_type acompanha a origem: uma digitalização (JPEG) volta como
image/jpeg; um PNG/WebP volta como image/png. Página de PDF volta como
image/jpeg (o render).
content_type diz o que a URL entrega — e nem sempre é imagem. Para um PDF,
a página armazenada continua sendo um PDF de uma página (o split preserva o
original), mas o preview entrega o render dessa página (image/jpeg). Um
cliente que assume "página = imagem" para TODO artefato acaba exibindo um arquivo
que o navegador recusa a mostrar embutido — em <iframe sandbox> o resultado é
uma resposta 200 seguida de ERR_ABORTED e um quadro em branco.
Use o campo para decidir: embuta apenas quando começar com image/; caso
contrário, ofereça a URL como link (target="_blank"). Você não consegue ler o
content type direto da URL assinada, porque ela é de outra origem — por isso o
campo existe.
GETAcompanhar ao vivo (SSE)#
GET /v1/document-ai/jobs/{jobId}/stream responde text/event-stream e carrega
o job e o resultado na mesma conexão — pensado para uma TELA acompanhando um
documento. Para integração servidor-a-servidor prefira o callback (§ Callback):
ele é push, não consome requisição e sobrevive a reinício do seu processo.
curl -N https://agents-api.catcher.one/v1/document-ai/jobs/$JOB_ID/stream \
-H "X-API-Key: $CATCHER_API_KEY" \
-H 'Accept: text/event-stream'
| Evento | Quando | Payload |
|---|---|---|
snapshot |
ao abrir | estado COMPLETO do job — um cliente que conecta tarde já renderiza |
progress |
só quando algo mudou | mesmo shape do snapshot |
done |
job terminal | { "state": "COMPLETED" } — pare de reconectar |
reconnect |
conexão atingiu a idade máxima (10 min) | reconecte; o job continua |
Cada payload é a view do job acrescida de:
{
"…campos do job…",
"result": { "…o mesmo shape de GET /jobs/{id}/result…" },
"live": {
"models": [{ "model": "x-ai/grok-4.5", "provider": "xAI", "latency_ms": 10900, "fields": 3, "confidence": 0.9, "page_number": 1 }],
"expected_models": 2,
"expected_answers": 2,
"answered": 1,
"candidate_count": 6,
"cost_usd": "0.008700"
}
}
expected_answers é o denominador honesto do progresso (answered / expected_answers):
progress_percent conta PÁGINAS, e num documento de uma página ele fica em 0 %
até o fim. live nunca traz bytes do documento, prompt ou schema — só
contabilidade.
Um job terminal recebe snapshot seguido de done e a conexão fecha. Uma queda
de conexão não é erro: reconecte e o snapshot traz o estado atual.
POSTRevisar#
GET /reviews lista jobs pendentes e o histórico append-only. Os candidatos
incluem field, model, provider, value e confidence.
curl -X POST \
"https://agents-api.catcher.one/v1/document-ai/reviews/$JOB_ID/decisions" \
-H "X-API-Key: $CATCHER_API_KEY" \
-H "Idempotency-Key: review-550e8400" \
-H "Content-Type: application/json" \
-d '{
"action": "CORRECT",
"corrections": {"cpf":"12345678909"},
"note": "Confirmed against source image"
}'
action é APPROVE, CORRECT ou REJECT. A ação se aplica a todas as
decisões do job com needs_review:true; CORRECT exige correção válida para
cada uma. Outra chave pode acrescentar uma nova rodada append-only. Candidatos
e decisões antigas nunca são sobrescritos.
Uma correção cria uma decisão REVIEWED que supersede a decisão anterior.
/result projeta esse valor corrigido como resultado final autoritativo sem
apagar candidatos ou decisões de máquina.
Resposta 201 (200 em replay idêntico):
{
"id": "uuid-da-primeira-revisao",
"action": "CORRECT",
"records": [
{
"id": "uuid-da-revisao",
"field": "cpf",
"action": "CORRECT",
"created_at": "2026-07-27T20:35:00Z"
}
],
"idempotent_replay": false
}
POSTIdempotência#
Exigem uma chave de 8 a 191 caracteres, sem CR/LF/NUL: POST /schemas, POST /schemas/{schemaId}/versions, POST /policies, POST /policies/{policyId}/versions, POST /jobs e POST /reviews/{reviewId}/decisions.
- mesma chave + mesma request →
200, resposta original eidempotent_replay:true; - mesma chave + request alterada →
409 DOCUMENT_AI_IDEMPOTENCY_CONFLICT; - replay de upload não cria segundo job nem segunda cobrança;
- cada job fixa
schema_version_id,policy_version_id,prompt_version,prompt_checksum,routing_version,max_tokensetimeout_ms; sua policy também fixa a allowlist de modelos. Versões criadas depois não alteram o passado.
POSTCallback assinado#
Com callback_url, o outbox envia eventos
document_ai.job.needs_review, .completed, .failed ou .canceled.
Polling de /jobs/{jobId} e /result continua sendo a reconciliação
autoritativa.
Headers:
X-Catcher-Event: document_ai.job.needs_review
X-Catcher-Delivery-Id: uuid-estavel
X-Catcher-Timestamp: 1785184320
X-Catcher-Signature: v1=<hex-hmac-sha256>
Content-Type: application/json
Body:
{
"event": "document_ai.job.needs_review",
"job_id": "uuid-do-job",
"tenant_id": 42,
"external_reference": "adria:clinic:123:document:456",
"status": "NEEDS_REVIEW",
"result_url": "/v1/document-ai/jobs/uuid-do-job/result",
"occurred_at": "2026-07-27T20:32:00Z"
}
Obtenha o segredo específico do tenant uma vez e guarde-o no seu cofre:
curl -H "X-API-Key: $CATCHER_API_KEY" \
"https://agents-api.catcher.one/v1/document-ai/callback-secret?tenant_external_id=$TENANT"
{
"secret": "<segredo-do-tenant>",
"algorithm": "hmac-sha256",
"signature_version": "v1",
"signed_payload": "<unix-seconds>\\n<raw-body>",
"clock_tolerance_seconds": 300
}
Calcule HMAC-SHA256(secret, timestamp + "\n" + rawBody), compare em tempo
constante com o hex depois de v1=, rejeite relógio fora de ±300 s e deduplicate
pelo X-Catcher-Delivery-Id.
Falhas de rede e respostas não-2xx são tentadas até 8 vezes, com backoff exponencial começando em 30 s (máximo 24 h); depois vão para dead-letter. Redirects são recusados e DNS/IP público é revalidado em cada tentativa. Owner/admin pode observar o ledger, sem endpoint/payload privados:
GET /v1/document-ai/jobs/{jobId}/deliveries
Cada item informa delivery id, evento, status, tentativas, HTTP/error code e timestamps. Mantenha polling de job/resultado como reconciliação autoritativa.
DELETEPrivacidade e retenção#
- original, páginas e evidência bruta ficam privados e isolados por empresa+tenant; a API só emite preview pré-assinado de cinco minutos;
- conteúdo de documento, prompt e output completo não entram nos logs;
- provider recebe bytes inline de uma página, não uma URL remota;
- retenção atual é 30 dias; expiração apaga objetos antes das linhas;
DELETE /jobs/{jobId}aceitaCOMPLETED,FAILEDeCANCELED; verifica a ausência da versão corrente, versões históricas e delete markers, persiste tombstone hash-only e então remove original, páginas, resultados, revisões e callbacks. Se há claim ativo, responde409 DOCUMENT_AI_INVALID_TRANSITIONcomRetry-After: 1;DELETE /schemas/{schemaId}e/policies/{policyId}arquivam, mantendo versões já referenciadas para auditoria;- retry reutiliza tentativas de modelo concluídas e nunca refatura a mesma tentativa.
GETOverview#
{
"total_documents": 12,
"completed_documents": 8,
"needs_review": 2,
"failed_documents": 1,
"completion_rate": 66.6666666667,
"review_rate": 16.6666666667,
"median_latency_ms": 940,
"cost_usd": 0.08431
}
As taxas são percentuais (0..100). median_latency_ms é null quando ainda
não há medição — uma tentativa cuja latência não foi cronometrada não é uma
resposta de 0 ms. Trate null como "sem dado", nunca como zero.
GETErros de Document AI#
Além do envelope padrão:
error_code |
HTTP | Significado |
|---|---|---|
UNAUTHORIZED |
401 | credencial ausente/inválida |
INSUFFICIENT_PERMISSIONS |
403 | role agent numa rota owner/admin |
TENANT_EXTERNAL_ID_INVALID |
400 | tenant opaco malformado |
TENANT_NOT_FOUND |
404 | tenant não pertence à empresa |
INVALID_JSON |
400 | JSON malformado ou campo desconhecido |
DOCUMENT_AI_NOT_CONFIGURED |
503 | dependência privada ausente ou callbacks não configurados |
DOCUMENT_AI_SCHEMA_INVALID |
400 | schema, política ou correção inválida |
DOCUMENT_AI_INVALID_FILE |
400 | multipart/MIME/arquivo/página inválido |
DOCUMENT_AI_FILE_TOO_LARGE |
400 | arquivo acima de 20 MiB |
CALLBACK_URL_INVALID |
400 | callback não é HTTPS público permitido |
IDEMPOTENCY_KEY_REQUIRED |
400 | chave ausente ou fora de 8..191 |
DOCUMENT_AI_IDEMPOTENCY_CONFLICT |
409 | chave reutilizada para outra request |
DOCUMENT_AI_NOT_FOUND |
404 | recurso ausente ou fora do tenant |
DOCUMENT_AI_LIMIT_EXCEEDED |
429 | orçamento durável de tentativas do job/tenant atingido |
DOCUMENT_AI_INVALID_TRANSITION |
409 | estado não permite a operação; delete com claim ativo inclui Retry-After: 1 |
RATE_LIMITED |
429 | limite global/plano/upload atingido |
SERVICE_UNAVAILABLE |
503 | storage privado/preview indisponível |
last_error_code no job é diagnóstico operacional, não o error_code da
resposta HTTP.