Documentação

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 details são { type, severity }, sem o details opcional que o AlertData de diagnosis.allAlerts carrega. 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 como SectionAlert para 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, deliveredAt e diagnosisDate sã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 dados
  • requestUid / cost / provider
  • addons.*.response — payloads internos dos add-ons de enriquecimento
  • cnhDocumentUrl (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" e diagnosis.hasReportData é false.
  • diagnosis.validity, .blocks, .medicalExams e .toxicological (e os respetivos espelhos em normalized) continuam a ser objetos, não null: cada um traz o seu próprio status: "unknown", já que não há licença para diagnosticar. Só normalized.points e normalized.fines são null.
  • aiAnalysis vem sempre preenchido (nunca null): 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"
  }
}