# Guia de integração da API ZapCar

Documentação 2.0, atualizada em 29/10/2026. Fonte: https://www.zapcarconsulta.com.br/api-integradores/docs. Contrato oficial: https://api.zapcarconsulta.com.br/public/openapi.json.

## Regras essenciais

- A chave de API (zc_live_ em produção, zc_test_ no ambiente de teste) fica só no servidor, numa variável de ambiente (ZAPCAR_API_KEY). Nunca no navegador, em app mobile, no repositório ou em log.
- Autenticação: cabeçalho Authorization: Bearer <chave> (X-API-Key também é aceito). Base URL única: https://api.zapcarconsulta.com.br. Quem decide o ambiente é a chave.
- Consultas são assíncronas: POST /v1/consultas cria, debita o saldo e devolve um id com status "processando". O resultado sai em GET /v1/consultas/{id} (polling a cada 3 a 5 s, teto de cerca de 5 min) ou pelo webhook query.completed / query.failed.
- Estados públicos: processando, concluido e erro. concluido e erro são terminais: o mesmo id não muda mais. Consulta que termina em erro é estornada automaticamente.
- retryable: true = falha transitória (crie uma consulta NOVA depois de um backoff). false ou AUSENTE = não repita sozinho.
- Envie Idempotency-Key (8 a 255 caracteres, derivada do id do pedido no seu sistema) em todo POST que cobra: POST /v1/consultas, POST /v1/fipe e POST /v1/fipe/tabela. Retry com a mesma chave e o mesmo corpo não cobra de novo.
- Erro de requisição: envelope { erro, codigo, retryable, request_id }. No 429, respeite o Retry-After. Limite padrão: 120 requisições/min em produção, 60 no teste.
- Webhooks: X-ZapCar-Signature = t=<unix>,v1=<hex>, HMAC-SHA256 de "<t>.<corpo bruto>" com o secret do endpoint. Compare em tempo constante, recuse timestamp com mais de 5 minutos, responda 2xx em até 10 s e deduplique pelo id do evento.
- POST /v1/fipe e POST /v1/fipe/tabela são síncronos: o resultado vem na mesma resposta.
- Ambiente de teste: toda resposta traz ambiente: "test"; o desfecho é escolhido pela placa (TST0001 limpo, TST0002 gravame, TST0003 RENAJUD, TST0004 débitos, TST0005 sinistro e leilão, TST0404 erro definitivo, TST0503 erro transitório, TST9999 nunca conclui). Não gera documento (422 SANDBOX_SEM_DOCUMENTO).
- null significa "não verificado", nunca "nada consta". Dado pessoal (nome, CPF/CNPJ) segue a LGPD.
- Use só endpoints, campos e códigos desta documentação e do OpenAPI. Não invente parâmetros nem use rotas internas.

## 1. Antes de começar

1. Crie a conta em https://cliente.zapcarconsulta.com.br/primeiro-acesso (grátis, sem mensalidade).
2. No Portal do Cliente, em Conta › Integrações, gere a chave de teste (zc_test_). Ela aparece uma única vez.
3. Guarde a chave em ZAPCAR_API_KEY no ambiente do servidor. Para produção, gere a chave zc_live_ e adicione saldo na Carteira (pré-pago).

## 2. Cliente HTTP

Um cliente único lê a chave, aplica timeout e converte o envelope de erro em exceção. Exemplo em TypeScript (Node 18+):

`lib/zapcar.ts`

```ts
// lib/zapcar.ts: cliente da API ZapCar. SÓ NO SERVIDOR: a chave dá acesso ao saldo da conta.
const BASE = "https://api.zapcarconsulta.com.br";

export type StatusConsulta = "processando" | "concluido" | "erro";

export interface Servico {
  servico: string;            // slug do campo "servico" do POST /v1/consultas
  nome: string;
  requer: string[];           // campos obrigatórios no corpo
  preco?: number;             // o que será debitado da SUA conta (ausente quando é por UF)
  preco_por_uf?: { uf: string; preco: number; requer: string[]; disponivel?: boolean }[];
  sincrono?: boolean;         // FIPE: resposta na mesma chamada
  endpoint?: string;          // quando não é POST /v1/consultas
}

export interface ConsultaCriada {
  id: string;
  servico: string;
  status: "processando";
  valor_cobrado: number;
  saldo_restante: number;
  resultado_url: string;
  reaproveitada?: boolean;    // só no HTTP 200: idêntica já em processamento, nada cobrado
  ambiente?: "test";          // presente SOMENTE no sandbox
}

export interface Consulta {
  id: string;
  servico: string;
  status: StatusConsulta;
  schema_version: string;
  placa?: string;
  dados?: Record<string, unknown> | null;
  veiculo?: Record<string, unknown> | null;
  nao_verificado?: string[];
  pdf_url?: string;           // só em "concluido"
  erro?: string;              // só em "erro": frase para o usuário
  erro_codigo?: string;       // só em "erro"
  retryable?: boolean;        // só em "erro"; ausente = não classificado, não repita sozinho
  ambiente?: "test";
}

export interface ApiErro { erro: string; codigo: string; retryable: boolean; request_id?: string; detalhes?: unknown[]; saldo?: number }

export class ZapCarErro extends Error {
  readonly status: number;            // 0 = rede/timeout
  readonly codigo: string | null;
  readonly retryable: boolean;
  readonly requestId: string | null;
  readonly retryAfter: number | null; // segundos, só no 429
  readonly corpo: Partial<ApiErro>;
  constructor(status: number, corpo: Partial<ApiErro> = {}, retryAfter: number | null = null) {
    super(corpo.erro ?? "HTTP " + status);
    this.status = status;
    this.corpo = corpo;
    this.codigo = corpo.codigo ?? null;
    this.retryable = corpo.retryable === true;
    this.requestId = corpo.request_id ?? null;
    this.retryAfter = retryAfter;
  }
}

function chave(): string {
  const k = process.env.ZAPCAR_API_KEY ?? "";
  if (!/^zc_(test|live)_/.test(k)) throw new Error("Defina ZAPCAR_API_KEY com uma chave zc_test_ ou zc_live_");
  return k;
}

/** Resposta crua (para PDF e imagens). */
export async function zapcarFetch(caminho: string, init: RequestInit = {}): Promise<Response> {
  const headers = new Headers(init.headers);
  headers.set("Authorization", "Bearer " + chave());
  if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
  try {
    return await fetch(BASE + caminho, { ...init, headers, signal: init.signal ?? AbortSignal.timeout(30_000) });
  } catch (e) {
    // Timeout ou rede: não dá para saber se o servidor processou.
    // No POST que cobra, é a Idempotency-Key que torna o retry seguro.
    throw new ZapCarErro(0, { erro: "Falha de rede: " + (e as Error).message, codigo: "NETWORK_ERROR", retryable: true });
  }
}

/** JSON já conferido: devolve o corpo no 2xx, lança ZapCarErro no resto. */
export async function zapcar<T>(caminho: string, init: RequestInit = {}): Promise<T> {
  const resp = await zapcarFetch(caminho, init);
  const corpo = await resp.json().catch(() => ({}));
  if (!resp.ok) throw new ZapCarErro(resp.status, corpo, Number(resp.headers.get("Retry-After")) || null);
  return corpo as T;
}
```

`verificar-chave.ts`

```ts
import { zapcar, ZapCarErro, type Servico } from "./lib/zapcar";

try {
  const { servicos } = await zapcar<{ servicos: Servico[] }>("/v1/servicos"); // gratuito
  console.log("Chave válida:", servicos.length, "serviços liberados.");
} catch (e) {
  if (e instanceof ZapCarErro && e.status === 401) {
    console.error("Chave recusada:", e.codigo, "request_id:", e.requestId); // não repita: gere outra chave
  } else {
    throw e;
  }
}
```

## 3. Serviços e preços

GET /v1/servicos (gratuito) devolve { servicos: [...] } com o slug (servico), o nome, os campos obrigatórios (requer), o preço que será debitado da sua conta (preco, ou preco_por_uf no CRLV) e os opcionais. Leia o preço daqui em tempo de execução; não fixe no código.

Serviços liberados na API no momento em que este arquivo foi gerado:

- Consulta Simples: POST /v1/consultas com servico "consulta"; campos obrigatórios: placa; leia nao_verificado: categoria listada ali não foi checada nesta consulta. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/consulta
- Consulta Completa: POST /v1/consultas com servico "consulta-completa"; campos obrigatórios: placa; opcional incluir_fipe: true embute a FIPE em dados.fipe com preço adicional (opcionais.incluir_fipe em GET /v1/servicos). Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/consulta-completa
- Laudo ECV Premium: POST /v1/consultas com servico "ecv-premium"; campos obrigatórios: placa. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/ecv-premium
- Consulta Veicular: POST /v1/consultas com servico "consulta-veicular"; campos obrigatórios: placa. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/consulta-veicular
- Gravame: POST /v1/consultas com servico "gravame"; campos obrigatórios: placa. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/gravame
- Renajud: POST /v1/consultas com servico "renajud"; campos obrigatórios: placa. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/renajud
- Débitos + Código de Barras: POST /v1/consultas com servico "debitos"; campos obrigatórios: placa. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/debitos
- Débitos Estaduais: POST /v1/consultas com servico "debitos-estadual"; campos obrigatórios: placa, uf; vale qualquer uma das 27 UFs. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/debitos-estadual
- Histórico de Proprietários: POST /v1/consultas com servico "historico-proprietarios"; campos obrigatórios: placa. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/historico-proprietarios
- Detalhe Comunicação de Venda: POST /v1/consultas com servico "comunicacao-venda"; campos obrigatórios: placa; dado pessoal (LGPD): exige finalidade (NEGOCIACAO_VEICULO, ANALISE_CREDITO, VERIFICACAO_CADASTRAL ou OUTRA) e declaracao_finalidade: true; sem eles, 400 FINALIDADE_OBRIGATORIA antes de cobrar. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/comunicacao-venda
- Decodificação de Chassi: POST /v1/consultas com servico "decodificacao-chassi"; campos obrigatórios: chassi. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/decodificacao-chassi
- Decodificação de Motor: POST /v1/consultas com servico "decodificacao-motor"; campos obrigatórios: motor. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/decodificacao-motor
- Score de Crédito: POST /v1/consultas com servico "score-credito"; campos obrigatórios: documento; finalidade é registrada quando enviada; envie sempre. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/score-credito
- Situação do CPF: POST /v1/consultas com servico "situacao-cpf"; campos obrigatórios: documento, data_nascimento; finalidade é registrada quando enviada; envie sempre. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/situacao-cpf
- Cadastro Simples CPF: POST /v1/consultas com servico "cadastro-simples-cpf"; campos obrigatórios: documento; dado pessoal (LGPD): exige finalidade (NEGOCIACAO_VEICULO, ANALISE_CREDITO, VERIFICACAO_CADASTRAL ou OUTRA) e declaracao_finalidade: true; sem eles, 400 FINALIDADE_OBRIGATORIA antes de cobrar. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/cadastro-simples-cpf
- Cadastro Completo CPF: POST /v1/consultas com servico "cadastro-completo-cpf"; campos obrigatórios: documento; dado pessoal (LGPD): exige finalidade (NEGOCIACAO_VEICULO, ANALISE_CREDITO, VERIFICACAO_CADASTRAL ou OUTRA) e declaracao_finalidade: true; sem eles, 400 FINALIDADE_OBRIGATORIA antes de cobrar. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/cadastro-completo-cpf
- Cadastro Simples CNPJ: POST /v1/consultas com servico "cadastro-simples-cnpj"; campos obrigatórios: documento; dado pessoal (LGPD): exige finalidade (NEGOCIACAO_VEICULO, ANALISE_CREDITO, VERIFICACAO_CADASTRAL ou OUTRA) e declaracao_finalidade: true; sem eles, 400 FINALIDADE_OBRIGATORIA antes de cobrar. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/cadastro-simples-cnpj
- Cadastro Completo CNPJ: POST /v1/consultas com servico "cadastro-completo-cnpj"; campos obrigatórios: documento; dado pessoal (LGPD): exige finalidade (NEGOCIACAO_VEICULO, ANALISE_CREDITO, VERIFICACAO_CADASTRAL ou OUTRA) e declaracao_finalidade: true; sem eles, 400 FINALIDADE_OBRIGATORIA antes de cobrar. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/cadastro-completo-cnpj
- Tabela FIPE: POST /v1/fipe (não usa o campo servico); corpo: placa; responde na mesma chamada (sem id, sem polling, sem PDF); cobra por requisição e aceita Idempotency-Key. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/endpoints/fipe-placa
- Crédito Único CPF: POST /v1/consultas com servico "credito-unico-cpf"; campos obrigatórios: documento; dado pessoal (LGPD): exige finalidade (NEGOCIACAO_VEICULO, ANALISE_CREDITO, VERIFICACAO_CADASTRAL ou OUTRA) e declaracao_finalidade: true; sem eles, 400 FINALIDADE_OBRIGATORIA antes de cobrar. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/credito-unico-cpf
- Crédito Biro CPF: POST /v1/consultas com servico "credito-biro-cpf"; campos obrigatórios: documento, uf; dado pessoal (LGPD): exige finalidade (NEGOCIACAO_VEICULO, ANALISE_CREDITO, VERIFICACAO_CADASTRAL ou OUTRA) e declaracao_finalidade: true; sem eles, 400 FINALIDADE_OBRIGATORIA antes de cobrar. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/credito-biro-cpf
- Crédito Único CNPJ: POST /v1/consultas com servico "credito-unico-cnpj"; campos obrigatórios: documento; dado pessoal (LGPD): exige finalidade (NEGOCIACAO_VEICULO, ANALISE_CREDITO, VERIFICACAO_CADASTRAL ou OUTRA) e declaracao_finalidade: true; sem eles, 400 FINALIDADE_OBRIGATORIA antes de cobrar. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/credito-unico-cnpj
- Crédito Biro CNPJ: POST /v1/consultas com servico "credito-biro-cnpj"; campos obrigatórios: documento, uf; dado pessoal (LGPD): exige finalidade (NEGOCIACAO_VEICULO, ANALISE_CREDITO, VERIFICACAO_CADASTRAL ou OUTRA) e declaracao_finalidade: true; sem eles, 400 FINALIDADE_OBRIGATORIA antes de cobrar. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/credito-biro-cnpj
- CNH Nacional: POST /v1/consultas com servico "cnh-nacional"; campos obrigatórios: documento, cnh, uf; dado pessoal (LGPD): exige finalidade (NEGOCIACAO_VEICULO, ANALISE_CREDITO, VERIFICACAO_CADASTRAL ou OUTRA) e declaracao_finalidade: true; sem eles, 400 FINALIDADE_OBRIGATORIA antes de cobrar. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/cnh-nacional
- Débitos Trabalhistas (CNDT): POST /v1/consultas com servico "debitos-trabalhistas"; campos obrigatórios: documento; dado pessoal (LGPD): exige finalidade (NEGOCIACAO_VEICULO, ANALISE_CREDITO, VERIFICACAO_CADASTRAL ou OUTRA) e declaracao_finalidade: true; sem eles, 400 FINALIDADE_OBRIGATORIA antes de cobrar. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/debitos-trabalhistas
- Dívida Ativa da União: POST /v1/consultas com servico "divida-ativa"; campos obrigatórios: documento; dado pessoal (LGPD): exige finalidade (NEGOCIACAO_VEICULO, ANALISE_CREDITO, VERIFICACAO_CADASTRAL ou OUTRA) e declaracao_finalidade: true; sem eles, 400 FINALIDADE_OBRIGATORIA antes de cobrar. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/divida-ativa
- Código de Segurança: POST /v1/consultas com servico "codigo-seguranca"; campos obrigatórios: placa, renavam; entrega só o documento (dados: null): baixe por GET /v1/consultas/{id}/pdf. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/codigo-seguranca
- 2ª via do ATPV-e: POST /v1/consultas com servico "atpve"; campos obrigatórios: placa; exige aceite_atpve { aceito: true, versao } com a versão de GET /public/avisos/atpve, depois de mostrar o aviso ao usuário (sem ele: 422 ACEITE_ATPVE_OBRIGATORIO; versão antiga: 409 AVISO_ATPVE_DESATUALIZADO); entrega só o documento (dados: null): baixe por GET /v1/consultas/{id}/pdf. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/atpve
- Consulta NF-e: POST /v1/consultas com servico "consulta-nfe"; campos obrigatórios: chave. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/consulta-nfe
- CRLV Digital: POST /v1/consultas com servico "crlv"; campos obrigatórios: placa, uf; preço, disponibilidade e campos extras (renavam, cpf) variam por UF: leia preco_por_uf de GET /v1/servicos; entrega só o documento (dados: null): baixe por GET /v1/consultas/{id}/pdf. Formato: https://www.zapcarconsulta.com.br/api-integradores/docs/servicos/crlv

## 4. Criar a consulta

POST /v1/consultas com { servico, ...campos } e Idempotency-Key. 201 = criada e debitada (ou replay da mesma chave, sem novo débito); 200 = solicitação idêntica já em processamento (reaproveitada: true, nada cobrado). Erros: 400 (corrija o pedido), 402 SALDO_INSUFICIENTE (traz saldo), 409 IDEMPOTENCY_IN_PROGRESS (repita com a mesma chave), 422 (IDEMPOTENCY_KEY_REUSED, SERVICO_INDISPONIVEL, ACEITE_ATPVE_OBRIGATORIO), 429, 503.

`criar-consulta.ts`

```ts
import { zapcar, ZapCarErro, type ConsultaCriada } from "./lib/zapcar";

/** Cria a consulta. `pedidoId` é o id do pedido no SEU banco: estável entre retries. */
export async function criarConsulta(servico: string, campos: Record<string, unknown>, pedidoId: string, tentativa = 1): Promise<ConsultaCriada> {
  return zapcar<ConsultaCriada>("/v1/consultas", {
    method: "POST",
    headers: { "Idempotency-Key": pedidoId + "-t" + tentativa }, // tentativa nova = chave nova
    body: JSON.stringify({ servico, ...campos }),
  });
}

try {
  const c = await criarConsulta("consulta", { placa: "TST0001" }, "pedido-984281");
  console.log(c.id, c.status, c.reaproveitada ? "(reaproveitada, nada cobrado)" : "cobrado: R$ " + c.valor_cobrado);
} catch (e) {
  if (e instanceof ZapCarErro && e.status === 402) console.error("Saldo insuficiente:", e.corpo.saldo);
  else if (e instanceof ZapCarErro && e.status === 400) console.error("Corrija o pedido:", e.codigo, e.corpo.detalhes);
  else throw e;
}
```

## 5. Acompanhar até o estado terminal

GET /v1/consultas/{id} a cada 3 a 5 s, fora do request do usuário (job, fila, worker), com teto de cerca de 5 minutos. processando = continue; concluido e erro = terminal.

`acompanhar.ts`

```ts
import { zapcar, ZapCarErro, type Consulta } from "./lib/zapcar";

const dormir = (ms: number) => new Promise((r) => setTimeout(r, ms));

/** Devolve a consulta no estado terminal ("concluido" ou "erro"). Rode num job, nunca dentro do request do usuário. */
export async function aguardarConclusao(id: string, intervaloMs = 4000, tetoMs = 5 * 60_000): Promise<Consulta> {
  const limite = Date.now() + tetoMs;
  while (Date.now() < limite) {
    try {
      const c = await zapcar<Consulta>("/v1/consultas/" + encodeURIComponent(id));
      if (c.status !== "processando") return c;
    } catch (e) {
      if (!(e instanceof ZapCarErro)) throw e;
      if (e.status === 429) { await dormir((e.retryAfter ?? 5) * 1000); continue; }
      if (e.status !== 0 && e.status < 500) throw e; // 401, 404: não adianta repetir
    }
    await dormir(intervaloMs);
  }
  throw new Error("Timeout local: a consulta " + id + " segue em processamento; volte a consultá-la depois.");
}

const final = await aguardarConclusao(process.argv[2] ?? "");
console.log(final.status, final.status === "erro" ? final.erro_codigo : final.pdf_url);
```

## 6. Ler o resultado

Na consulta concluida: schema_version (1.x; minor só acrescenta campos), veiculo (bloco normalizado pela ZapCar, nos serviços de veículo), dados (formato de cada serviço), proprietario, risco (família da Consulta Completa), nao_verificado (Consulta Simples) e pdf_url. null = não verificado. Na consulta com erro: erro (frase pronta para o usuário), erro_codigo e retryable.

`resultado.ts`

```ts
import { zapcar, type Consulta } from "./lib/zapcar";

export type Resumo =
  | { estado: "aguardando" }
  | { estado: "falhou"; mensagem: string; codigo?: string; podeTentarDeNovo: boolean }
  | { estado: "pronto"; veiculo: Record<string, unknown> | null; dados: Record<string, unknown> | null; naoVerificado: string[]; temDocumento: boolean };

export async function resumoDaConsulta(id: string): Promise<Resumo> {
  const c = await zapcar<Consulta>("/v1/consultas/" + encodeURIComponent(id));
  if (c.status === "processando") return { estado: "aguardando" };
  if (c.status === "erro") {
    // retryable ausente = não classificado: trate como false.
    return { estado: "falhou", mensagem: c.erro ?? "Consulta não concluída.", codigo: c.erro_codigo, podeTentarDeNovo: c.retryable === true };
  }
  return {
    estado: "pronto",
    veiculo: c.veiculo ?? null,          // forma estável, normalizada pela ZapCar
    dados: c.dados ?? null,              // formato do serviço; null em serviços só de documento
    naoVerificado: c.nao_verificado ?? [], // campo listado aqui é "não verificado", nunca "nada consta"
    temDocumento: !!c.pdf_url,
  };
}

console.log(await resumoDaConsulta(process.argv[2] ?? ""));
```

## 7. Baixar o documento

GET /v1/consultas/{id}/pdf devolve o arquivo (gratuito). Confie no Content-Type: application/pdf, image/png ou image/jpeg. 400 PDF_NOT_READY é transitório. Sirva o arquivo ao seu usuário por uma rota do seu backend.

`baixar-pdf.ts`

```ts
import { writeFile } from "node:fs/promises";
import { zapcarFetch, ZapCarErro } from "./lib/zapcar";

export async function baixarDocumento(id: string): Promise<{ bytes: Buffer; tipo: string; nome: string | null }> {
  const resp = await zapcarFetch("/v1/consultas/" + encodeURIComponent(id) + "/pdf");
  if (!resp.ok) throw new ZapCarErro(resp.status, await resp.json().catch(() => ({}))); // PDF_NOT_READY é retryable
  const disp = resp.headers.get("content-disposition") ?? "";
  return {
    bytes: Buffer.from(await resp.arrayBuffer()),
    tipo: resp.headers.get("content-type") ?? "application/octet-stream", // pdf, png ou jpeg
    nome: /filename="?([^";]+)"?/.exec(disp)?.[1] ?? null,
  };
}

const doc = await baixarDocumento(process.argv[2] ?? "");
await writeFile(doc.nome ?? "documento.pdf", doc.bytes);
```

## 8. Webhooks

Cadastre a URL HTTPS em Conta › Integrações › Webhooks. Eventos: query.completed, query.failed, webhook.test e os do monitoramento (vehicle.*). O evento não traz o resultado: busque GET /v1/consultas/{data.id}. Reentregas (até 6, com backoff a partir de 10 s) trazem o mesmo id de evento. Detalhes: https://www.zapcarconsulta.com.br/api-integradores/docs/webhooks

`lib/assinatura.ts`

```ts
import crypto from "node:crypto";

/**
 * X-ZapCar-Signature: t=<unix>,v1=<hex>. O HMAC-SHA256 é de "<t>.<corpo bruto>".
 * corpoBruto: exatamente como chegou (parse + stringify invalida a assinatura).
 */
export function verificarAssinatura(corpoBruto: string | Buffer, cabecalho: string | null | undefined, secret: string, toleranciaS = 300): boolean {
  if (!cabecalho || !secret) return false;
  const partes = cabecalho.split(",").map((p) => p.trim().split("=") as [string, string | undefined]);
  const t = partes.find(([k]) => k === "t")?.[1];
  const assinaturas = partes.filter(([k]) => k === "v1").map(([, v]) => v ?? "");
  if (!t || !/^\d+$/.test(t) || assinaturas.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranciaS) return false; // replay

  const esperado = crypto.createHmac("sha256", secret).update(t + ".").update(corpoBruto).digest();
  return assinaturas.some((v1) => {
    const recebido = Buffer.from(v1, "hex");
    return recebido.length === esperado.length && crypto.timingSafeEqual(recebido, esperado);
  });
}
```

`lib/webhook.ts`

```ts
import { verificarAssinatura } from "./assinatura";
import { zapcar, type Consulta } from "./zapcar";

export interface EventoZapCar {
  id: string;            // id do EVENTO: deduplique por ele
  type: string;          // query.completed, query.failed, webhook.test, vehicle.*
  created_at: string;
  data: { id?: string; servico_slug?: string; status_publico?: "concluido" | "erro"; [k: string]: unknown };
}

/**
 * Independente de framework: receba o corpo BRUTO (string ou Buffer) e o cabeçalho.
 * Devolve o status HTTP a responder e, se válido, a tarefa para rodar DEPOIS da resposta.
 */
export function tratarWebhook(corpoBruto: string | Buffer, assinatura: string | null, jaVisto: (id: string) => boolean) {
  if (!verificarAssinatura(corpoBruto, assinatura, process.env.ZAPCAR_WEBHOOK_SECRET ?? "")) return { status: 401 as const };
  const evento = JSON.parse(corpoBruto.toString()) as EventoZapCar;
  if (jaVisto(evento.id)) return { status: 200 as const }; // reentrega: já processado
  return { status: 200 as const, depois: () => processar(evento) };
}

async function processar(evento: EventoZapCar): Promise<void> {
  if (!evento.type.startsWith("query.") || !evento.data.id) return;
  const c = await zapcar<Consulta>("/v1/consultas/" + encodeURIComponent(evento.data.id));
  console.log(evento.type, c.id, c.status); // atualize o pedido aqui
}
```

## 9. Erros e retry

`lib/erros.ts`

```ts
import { ZapCarErro, type Consulta } from "./zapcar";

export type Acao = "bug" | "trocar_chave" | "recarregar_saldo" | "esperar_retry_after" | "repetir_com_backoff" | "corrigir_pedido";

export function acaoParaErro(e: unknown): Acao {
  if (!(e instanceof ZapCarErro)) return "bug";
  if (e.status === 401) return "trocar_chave";
  if (e.status === 402) return "recarregar_saldo";
  if (e.status === 429) return "esperar_retry_after";
  if (e.retryable) return "repetir_com_backoff";
  return "corrigir_pedido";
}

/** Só para GET ou POST com Idempotency-Key estável entre as tentativas. */
export async function comRetry<T>(fn: () => Promise<T>, tentativas = 4, baseMs = 1000): Promise<T> {
  for (let i = 1; ; i++) {
    try {
      return await fn();
    } catch (e) {
      const acao = acaoParaErro(e);
      if (i >= tentativas || (acao !== "repetir_com_backoff" && acao !== "esperar_retry_after")) throw e;
      const espera = acao === "esperar_retry_after" ? ((e as ZapCarErro).retryAfter ?? 5) * 1000 : baseMs * 2 ** (i - 1) + Math.random() * baseMs;
      await new Promise((r) => setTimeout(r, espera));
    }
  }
}

/** Consulta terminada em "erro": o que mostrar e se vale uma consulta NOVA. */
export function desfechoDaFalha(c: Consulta) {
  return {
    mensagem: c.erro ?? "Não foi possível concluir a consulta.", // frase pronta para o usuário
    codigo: c.erro_codigo ?? null,
    criarNovaConsulta: c.retryable === true, // ausente = não classificado: não repita sozinho
  };
}
```

## 10. Evitar cobrança duplicada

`lib/sem-duplicar.ts`

```ts
import { zapcar, ZapCarErro, type ConsultaCriada } from "./zapcar";

interface Pedido { id: string | number; servico: string; campos: Record<string, unknown> }

/** Mesma tentativa = mesma chave e mesmo corpo. Chave nova só para consulta NOVA. */
export async function criarSemDuplicar(pedido: Pedido, tentativa = 1): Promise<ConsultaCriada> {
  const chave = "pedido-" + pedido.id + "-t" + tentativa;
  const corpo = JSON.stringify({ servico: pedido.servico, ...pedido.campos });
  for (let i = 1; ; i++) {
    try {
      return await zapcar<ConsultaCriada>("/v1/consultas", { method: "POST", headers: { "Idempotency-Key": chave }, body: corpo });
    } catch (e) {
      if (!(e instanceof ZapCarErro)) throw e;
      if (e.codigo === "IDEMPOTENCY_KEY_REUSED") throw new Error("Bug: chave " + chave + " reusada com outro corpo");
      const repetir = e.status === 0 || e.status === 429 || e.status === 409 || e.status >= 500;
      if (!repetir || i === 5) throw e;
      await new Promise((r) => setTimeout(r, e.status === 429 ? (e.retryAfter ?? 5) * 1000 : 1000 * 2 ** (i - 1)));
    }
  }
}
```

## 11. Checklist de produção

- Chave zc_live_ só no servidor, em variável de ambiente; chave de teste separada.
- Idempotency-Key estável em todo POST que cobra.
- Polling com teto, ou webhook com assinatura validada e deduplicação.
- 429 respeitando Retry-After; backoff com jitter só onde retryable é true.
- request_id nos logs; nenhum dado pessoal ou chave em log.
- Alerta de saldo baixo (GET /v1/saldo).
- Checklist completo: https://www.zapcarconsulta.com.br/api-integradores/docs/guias/producao

Exemplos em outras linguagens (cURL, JavaScript, Node.js com Express, Next.js, Python e PHP): https://www.zapcarconsulta.com.br/api-integradores/docs/exemplos
