Shape do Payload
Estrutura canónica de resposta dos endpoints e do payload de webhook. Campos expostos e garantia anti-leak de dados internos do provider.
Estrutura canónica
O mesmo objeto é retornado pelo GET /api/v1/reports/{reportId} e enviado no corpo do webhook —
o formato não muda conforme a porta por onde você pediu o relatório.
Prop
Type
O detalhe de cada objeto aninhado:
{
reportId: string;
externalRef: string | null;
status: "PROCESSING" | "DELIVERED" | "ERROR";
// O `cpf` é o que você enviou. Os outros campos vêm da consulta quando ela
// encontra a habilitação, com uma exceção: `name` pode ser o nome do seu
// próprio cadastro. Num relatório `NO_LICENSE` nada abaixo de `cpf` veio da
// consulta. Ver a seção sobre esse status.
driver: {
id: string;
cpf: string | null;
name: string | null;
licenseNumber: string | null;
licenseCategory: string | null; // ex.: "A" | "B" | "AB" | "C" | "D" | "E"
expiryDate: string | null; // ISO 8601 date
issueState: string | null; // sigla UF, ex.: "SP"
};
// Diagnóstico curado. null enquanto a consulta não respondeu — NÃO enquanto
// status !== "DELIVERED": uma entrega parcial reporta PROCESSING e já traz isto.
// As seções marcadas com `?` são omitidas quando não há dados para elas: a
// chave não aparece no JSON. Não teste por `=== null`.
diagnosis: {
status: "VALID" | "WARNING" | "BLOCKED" | "EXPIRED" | "UNKNOWN" | "NO_LICENSE";
hasReportData: boolean;
hasPartialData: boolean;
validity: object;
category: object;
blocks: object;
medicalExams: object;
toxicological: object;
points?: object;
fines?: object;
rollingPoints?: object;
autosuspensionRisk?: object;
allAlerts: object[];
diagnosisDate: string; // ISO 8601
} | null;
// Resumos normalizados por seção (espelham o diagnóstico). Aqui a ausência é
// sempre `null` e a chave existe, ao contrário do bloco acima.
normalized: {
validity: object | null;
blocks: object | null;
medicalExams: object | null;
toxicological: object | null;
points: object | null;
fines: object | null;
};
// Análise em linguagem natural gerada por IA a partir do diagnóstico.
// Opcional e anulável — ver nota abaixo.
aiAnalysis: {
markdown: string; // texto em markdown (pt-BR)
status: string | null; // veredito: VALID | WARNING | BLOCKED | EXPIRED | UNKNOWN | NO_LICENSE
generatedAt: string; // ISO 8601
} | null;
// Paridade de campos com o relatório — o registro por trás do veredito.
// null enquanto a consulta não respondeu. Ver "As seções de `details`" abaixo.
details: {
personal: object | null;
license: object | null;
medicalExams: object | null;
toxicological: object | null;
courses: object | null;
stateLookup: object | null; // null quando a consulta estadual não rodou
} | null;
createdAt: string; // ISO 8601
deliveredAt: string | null; // ISO 8601, preenchido quando DELIVERED
}As seções de details
diagnosis responde qual é o veredito. details traz o registro por trás dele — os campos que
um resumo necessariamente descarta, e sem os quais não dá para reconstruir a tela.
Todo campo de texto ou data em details vem no formato { value, formatted }: value é o valor
tipado (datas em ISO 8601, null quando a fonte não respondeu) e formatted é a string pronta para
exibir. Os booleanos e contadores vêm crus — com uma exceção, details.license.isEAR, que é
booleano e mesmo assim vem em { value, formatted }.
Duas notas sobre formatted e sobre os alertas, porque as duas surpreendem:
formattedé""quando o campo inteiro não veio da extração. O travessão (—) aparece quando o campo existe e está vazio. Trate string vazia como ausência, não desenhe"".- Os alertas de
detailssão{ type, severity }, sem odetailsopcional que oAlertDatadediagnosis.allAlertscarrega. Esse objeto pode conter contexto do provedor, e a garantia anti-leak o descarta nesta fronteira. É a mesma lista, projetada mais estreita — abaixo o tipo aparece comoSectionAlertpara não confundir os dois.
details.personal: {
status: SectionStatus;
name; cpf; birthDate; fatherName; motherName; birthLocation; nationality;
identityDocument; identityDocumentType; identityIssuer; identityIssuerState; gender;
alerts: SectionAlert[];
}
details.license: {
status: SectionStatus;
validityStatus: "valid" | "expired" | "expiring_soon" | "unknown";
licenseNumber; category; renachFormNumber; licenseFormNumber;
firstLicenseDate; issueDate; expiryDate; issueLocation; issueState;
issueStateInferred: boolean; // a UF foi deduzida do nº de registro
licenseStatus;
isEAR; // booleano, mas vem em { value, formatted } — a exceção
alerts: SectionAlert[];
}
details.medicalExams: {
status: SectionStatus;
totalCount; validCount; expiredCount: number;
hasExpiredExams: boolean;
// Os 11 campos de cada exame — `diagnosis.medicalExams.exams[]` traz 6 deles.
exams: Array<{
type; examDate; result; validUntil; desiredCategory; allowedCategory; city; state;
resultStatus: string;
isExpired: boolean;
daysUntilExpiry: number | null;
}>;
alerts: SectionAlert[];
}
details.toxicological: {
status: SectionStatus;
// `hasExam` só é true quando a resposta traz evidência de exame: uma data, ou uma
// situação que julga um. Um envelope preenchido com "não se aplica" não é evidência.
hasExam; isRequired; isExpired: boolean;
collectionDate; useDate; situation; expiryDate;
// "regular" | "irregular" | "pending" | "not_applicable" | "unavailable" | "unknown".
// `not_applicable` = a categoria não exige; `unavailable` = a consulta falhou. Este é o
// único campo que separa os dois — o veredito de um exame exigido e não comprovado é
// BLOCKED nos dois casos. Trate valores novos com um default.
situationStatus: string;
daysUntilExpiry: number | null;
alerts: SectionAlert[];
}
details.courses: {
status: SectionStatus;
totalCount; validCount; expiredCount: number;
hasExpiredCourses: boolean;
courses: Array<{
name; startDate; endDate; workload; category; modality; expiryDate; city; state;
isExpired: boolean;
daysUntilExpiry: number | null;
}>;
alerts: SectionAlert[];
}
// null quando a consulta estadual não rodou.
details.stateLookup: {
stateCode: string; // sigla UF
status: SectionStatus;
pointsBalance: {
currentPoints; maxPoints; remainingPoints: number | null;
isAtRisk; isWarning: boolean;
inferredPoints: number | null; // soma a partir da lista de multas
hasPointsDiscrepancy: boolean; // o saldo oficial diverge da soma
};
totalFines; totalFinesLast12Months; pendingFines: number;
hasFines: boolean;
// O histórico COMPLETO. `diagnosis.fines` é a janela de 12 meses da Lei 14.071.
fines: Array<{
licensePlate; issuingAgency; fineNumber; violationCode; violationDescription; fineDate;
pointsValue: number | null;
severity: "light" | "medium" | "serious" | "very_serious" | null;
status: string; // situação de pagamento
scoresOnDriver: boolean | null; // false quando responde o proprietário (CTB 257 §8º)
}>;
alerts: SectionAlert[];
}
// Os alertas de `details` são a projeção estreita: o `details` opcional que o `AlertData`
// de `diagnosis.allAlerts` carrega pode trazer contexto do provedor, e não cruza esta fronteira.
type SectionAlert = { type: string; severity: "info" | "warning" | "error" | "critical" };Duas listas de multas, propósitos diferentes
diagnosis.fines é a janela móvel de 12 meses — é ela que decide a pontuação pela Lei 14.071.
details.stateLookup.fines é o histórico inteiro, com o status de pagamento de cada autuação. Use a
primeira para pontuação e a segunda para histórico; não as troque.
As seções do diagnóstico
Cada seção acima é um objeto com forma própria. Elas são o contrato, e valem igual no Servidor MCP. Para ver cada campo governando a tela, com as variações lado a lado, veja Montar a UI.
Duas convenções valem para todos os blocos abaixo:
statusé sempre"success" | "warning" | "error" | "pending" | "unknown".- Toda data chega como string ISO 8601. As de calendário (vencimento, multa, exame) devem ser lidas
em UTC; só
createdAt,deliveredAtediagnosisDatesão instantes.
validity: {
status: SectionStatus;
validityStatus: "valid" | "expired" | "expiring_soon" | "unknown";
isExpired: boolean;
daysUntilExpiry: number | null; // null quando não há data
expiryDate: string | null;
alerts: AlertData[];
}
category: {
status: SectionStatus;
category: string | null; // ex.: "A" | "B" | "AB" | "C" | "D" | "E"
requiresToxicological: boolean; // verdadeiro para C, D e E
alerts: AlertData[];
}
blocks: {
status: SectionStatus;
hasActiveBlocks: boolean;
totalBlocks: number;
activeBlocks: number;
suspensionsCount: number;
revocationsCount: number;
hasUnresolvedBlocks: boolean;
unresolvedBlocksCount: number;
hasFinesDuringSuspension: boolean;
finesDuringSuspensionCount: number;
finesDuringSuspension: Array<{
violationCode: string | null;
description: string;
fineDate: string | null;
severity: "light" | "medium" | "serious" | "very_serious";
licensePlate: string | null;
fineNumber: string | null;
suspensionProcessNumber: string | null;
}>;
alerts: AlertData[];
stateDeclaredBlock?: { isBlocked: boolean; statusLabel: string | null; detailedBlocks: number };
federalBlocks?: Array<{
blockDate: string | null;
penaltyDays: string | null;
state: string | null;
isActive: boolean;
}>;
// `revocations` tem exatamente a mesma forma de `suspensions`.
suspensions?: Array<{
processNumber: string;
description: string | null;
penaltyDays: string | null;
penaltyStartDate: string | null;
penaltyEndDate: string | null;
state: string | null;
isActive: boolean;
}>;
}
medicalExams: {
status: SectionStatus;
totalExams: number;
expiredExams: number;
hasExpiredExams: boolean;
alerts: AlertData[];
exams?: Array<{
type: string;
date: string | null;
result: string | null;
validUntil: string | null;
isExpired: boolean;
daysUntilExpiry: number | null;
}>;
}
toxicological: {
status: SectionStatus;
hasExam: boolean;
isRequired: boolean; // decorre de `category.requiresToxicological`
isExpired: boolean;
daysUntilExpiry: number | null;
expiryDate: string | null;
collectionDate: string | null;
cnhIssueDate: string | null;
// Calendário sugerido para o próximo exame — datas futuras, não histórico.
suggestedExamDates: Array<{ date: string; isPast: boolean; daysFromNow: number }>;
alerts: AlertData[];
}
points: { // seção opcional: a chave pode não existir
status: SectionStatus;
currentPoints: number; // soma da janela móvel de 12 meses
limit: number; // 40 | 30 | 20 — ver a nota da Lei 14.071 abaixo
remainingPoints: number;
percentage: number; // 0–100, satura em 100
riskLevel: "low" | "medium" | "high" | "critical";
isOverLimit: boolean;
gravissimasCount: number;
isEAR: boolean; // condutor profissional
alerts: AlertData[];
}
fines: { // seção opcional
status: SectionStatus;
totalFines: number;
severityBreakdown: { light: number; medium: number; serious: number; very_serious: number };
fines: Array<{
violationCode: string | null;
description: string;
points: number;
value: number | null; // em reais
fineDate: string | null;
dueDate: string | null;
severity: "light" | "medium" | "serious" | "very_serious";
licensePlate: string | null;
fineNumber: string | null;
}>;
alerts: AlertData[];
}
rollingPoints: { // seção opcional
perFine: RollingPointsMoment[];
everExceeded: boolean;
worstMoment: RollingPointsMoment | null;
}
// Um momento da série: quanto o condutor somava logo após cada multa.
type RollingPointsMoment = {
fineNumber: string | null;
fineDate: string | null;
pointsValue: number;
rollingTotal: number; // soma na janela de 12 meses naquele instante
limitAtMoment: number; // o limite varia ao longo da série
gravissimasAtMoment: number;
exceeded: boolean;
};
autosuspensionRisk: { // seção opcional
atRisk: boolean;
hasActiveBlock: boolean;
autosuspensiveFines: Array<{
violationCode: string | null;
description: string;
ctbArticle: string | null; // o artigo do CTB vive AQUI, não em `fines[]`
suspensionMonths: number | null;
fineDate: string | null;
fineNumber: string | null;
}>;
}
// `type` não é enum fechado: os valores em snake_case vêm das seções (`cnh_expired`,
// `federal_block`, `rolling_limit_exceeded`, …) e os em CAIXA ALTA vêm da pontuação
// (`CRITICAL_POINTS`, `HIGH_POINTS`, `GRAVISSIMAS_DETECTED`). Trate o desconhecido com um default.
type AlertData = {
type: string;
severity: "info" | "warning" | "error" | "critical";
details?: Record<string, unknown>;
};`points.limit` não é fixo
O limite da Lei 14.071/2020 é 40 pontos (nenhuma infração gravíssima nos últimos 12 meses, ou condutor EAR), 30 (uma gravíssima) ou 20 (duas ou mais). O campo já chega calculado — fixar 20 no código acusa "acima do limite" em condutor regular.
Pelo mesmo motivo, percentage satura em 100: 45/20 e 60/20 produzem o mesmo valor. Exiba
sempre currentPoints e limit em texto.
diagnosis.blocks.stateDeclaredBlock
A consulta estadual responde a situação da CNH em dois lugares independentes: uma lista de bloqueios detalhados e uma resposta direta a "esta CNH está bloqueada?". Os dois podem divergir — a consulta pode acusar bloqueio sem detalhar nenhum.
stateDeclaredBlock?: {
isBlocked: boolean; // resposta direta da consulta estadual
statusLabel: string | null; // rótulo do estado, ex.: "Bloqueada"
detailedBlocks: number; // quantos bloqueios vieram detalhados
}Quando isBlocked é true e detailedBlocks é 0, o estado informa bloqueio sem
detalhamento — o diagnosis.status é BLOCKED mesmo com a lista de bloqueios vazia.
O campo é ausente quando a consulta estadual não chegou a rodar. Ausente e
isBlocked: false são respostas diferentes: só a segunda é evidência de que a CNH
está regular no estado. Nesse caso status do relatório vem como PROCESSING e
o webhook report.partially_delivered é enviado.
Campos nunca expostos
Garantia anti-leak
Os seguintes campos nunca aparecem na resposta da API, mesmo existindo internamente no relatório:
payload/response— respostas brutas dos provedores de dadosrequestUid/cost/provideraddons.*.response— payloads internos dos add-ons de enriquecimentocnhDocumentUrl(URL interna),monitoring(estado interno),reportsUsed(ids federais internos)- Contadores internos de retry e tempos de pipeline
A garantia é construída por allow-list (o serializer monta o objeto campo a campo, nunca por exclusão) e validada por snapshot tests automáticos em toda PR que toca o serializer.
Comportamento quando status não é DELIVERED
`PROCESSING` não implica payload vazio
PROCESSING cobre dois estados internos. Num relatório ainda em processamento, diagnosis,
details e os campos de normalized são null, deliveredAt é null, e o driver pode ter
apenas id e cpf se o enriquecimento não completou.
Numa entrega parcial — a consulta federal respondeu e a estadual não — o status também é
PROCESSING, e diagnosis e details já vêm preenchidos com o que foi apurado. É esse o corpo
do webhook report.partially_delivered.
Por isso a condição de leitura é o campo, não o estado: teste diagnosis !== null /
details !== null para saber se há o que ler, e o status para saber se ainda vem mais. Um
if (status !== "DELIVERED") return; descarta um relatório que tem dado.
O campo aiAnalysis é gerado automaticamente também para relatórios criados via API, e segue a
mesma regra dos dois acima: acompanha a existência do dado, não o status. É gravado antes de o
status terminal ser escrito, então uma entrega parcial — que reporta PROCESSING — já o traz, tanto
no GET /api/v1/reports/{reportId} quanto no corpo do webhook. Continua null enquanto a análise
não rodou, e pode continuar null num relatório já DELIVERED se a geração falhar: é
best-effort e nunca bloqueia nem atrasa a entrega. Por ser opcional e anulável, o campo é
retrocompatível: integrações existentes não precisam de nenhuma mudança.
diagnosis.status: "NO_LICENSE": CPF sem CNH emitida
Adição não-breaking
NO_LICENSE é um valor adicional num campo já existente: diagnosis.status (e aiAnalysis.status).
Uma integração que trata valores desconhecidos com um default não precisa de nenhuma mudança; uma
integração que faz switch explícito sobre diagnosis.status deve passar a tratar este caso.
Quando o CPF consultado nunca teve uma CNH emitida, o relatório é entregue normalmente: a resposta "não há CNH" é o resultado da consulta, não uma falha dela.
status(nível superior) é"DELIVERED", nunca"ERROR".diagnosis.statusé"NO_LICENSE"ediagnosis.hasReportDataéfalse.diagnosis.validity,.blocks,.medicalExamse.toxicological(e os respetivos espelhos emnormalized) continuam a ser objetos, nãonull: cada um traz o seu própriostatus: "unknown", já que não há licença para diagnosticar. Sónormalized.pointsenormalized.finessãonull.aiAnalysisvem sempre preenchido (nuncanull): um texto fixo em pt-BR explicando que não há CNH para o CPF, gerado sem chamar o modelo, portanto não pode falhar.
O bloco `driver` não foi confirmado pela consulta
Num relatório NO_LICENSE a consulta não devolveu dados de habilitação. Por isso licenseNumber,
licenseCategory, expiryDate e issueState vêm null, e name é o nome que você enviou no
pedido, não um nome confirmado em base oficial.
Isso muda o que o payload prova. Um name preenchido ao lado de "status": "NO_LICENSE" diz que o
CPF consultado não tem CNH emitida. Não diz que aquela pessoa foi identificada e está a dirigir sem
habilitação.
{
"status": "DELIVERED",
"diagnosis": {
"status": "NO_LICENSE",
"hasReportData": false,
"validity": { "status": "unknown", "...": "..." },
"...": "..."
},
"normalized": {
"validity": { "status": "unknown", "...": "..." },
"blocks": { "status": "unknown", "...": "..." },
"medicalExams": { "status": "unknown", "...": "..." },
"toxicological": { "status": "unknown", "...": "..." },
"points": null,
"fines": null
},
"details": {
"personal": { "name": { "value": "JOÃO DA SILVA", "formatted": "JOÃO DA SILVA" }, "...": "..." },
"license": { "licenseNumber": { "value": null, "formatted": "—" }, "...": "..." },
"medicalExams": { "totalCount": 0, "exams": [], "...": "..." },
"toxicological": { "hasExam": false, "...": "..." },
"courses": { "totalCount": 0, "courses": [], "...": "..." },
"stateLookup": null
},
"aiAnalysis": {
"markdown": "### Conclusão\nNão há CNH emitida para este CPF...",
"status": "NO_LICENSE",
"generatedAt": "2026-07-28T12:00:00Z"
}
}