Pular para o conteúdo
ZapCar
Developersv1

Integração com ERP automotivo

Como o sistema de uma revenda pede a consulta e mostra o resultado dentro do próprio ERP.

Como o sistema de uma revenda (ERP, DMS, sistema de despachante) pede uma consulta veicular e mostra o resultado na própria tela, sem expor a chave e sem pagar duas vezes pela mesma consulta.

Arquitetura

  • Só o backend do ERP fala com a ZapCar. A tela do ERP conversa com o seu backend; a chave fica numa variável de ambiente do servidor.
  • Uma linha por tentativa numa tabela sua, criada antes de chamar a API. A Idempotency-Key sai dessa linha: se o processo cair, o reprocessamento repete a mesma chave.
  • Resposta assíncrona para a tela. O backend devolve “em processamento” na hora; o resultado chega pelo webhook ou por um worker de acompanhamento.
  • A tela lê o bloco veiculo, a camada estável, e trata null como “não verificado”.

Passo a passo

  1. Passo 1: Crie a tabela de consultas

    SQLPostgreSQL
    -- Uma linha por TENTATIVA de consulta. O pedido do seu ERP (OS, ficha do
    -- veículo, proposta) é o pedido_ref; a Idempotency-Key deriva dele.
    CREATE TABLE consultas_zapcar (
      id              BIGSERIAL PRIMARY KEY,
      pedido_ref      TEXT        NOT NULL,
      tentativa       INT         NOT NULL DEFAULT 1,
      servico         TEXT        NOT NULL,
      placa           TEXT,
      idempotency_key TEXT        NOT NULL UNIQUE,
      zapcar_id       UUID        UNIQUE,
      status          TEXT        NOT NULL DEFAULT 'pendente', -- pendente | processando | concluido | erro
      erro_codigo     TEXT,
      retryable       BOOLEAN,
      resultado       JSONB,      -- guarde só o que a tela usa (LGPD: minimização)
      valor_cobrado   NUMERIC(10,2),
      criado_em       TIMESTAMPTZ NOT NULL DEFAULT now(),
      atualizado_em   TIMESTAMPTZ NOT NULL DEFAULT now(),
      UNIQUE (pedido_ref, servico, tentativa)
    );
    CREATE INDEX ON consultas_zapcar (status) WHERE status = 'processando';
  2. Passo 2: Exponha endpoints internos para a tela

    O backend cria a consulta, devolve o andamento e serve o PDF. O exemplo usa Node.js com Express e pg; a lógica é a mesma em qualquer linguagem.
    Node.jsbackend do ERP
    // Backend do ERP (Node.js 18+, Express e pg). A chave da ZapCar fica SÓ aqui.
    import express from "express";
    import pg from "pg";
    
    const app  = express();
    const db   = new pg.Pool({ connectionString: process.env.DATABASE_URL });
    const BASE = "https://api.zapcarconsulta.com.br";
    const CHAVE = process.env.ZAPCAR_API_KEY;
    
    async function zapcar(caminho, opcoes = {}) {
      const resp = await fetch(BASE + caminho, {
        ...opcoes,
        headers: { Authorization: `Bearer ${CHAVE}`, ...(opcoes.headers ?? {}) },
        signal: AbortSignal.timeout(30_000),
      });
      return resp;
    }
    
    // 1. A tela do ERP pede a consulta da placa. Responde rápido (202) e deixa o
    //    acompanhamento para o worker ou para o webhook.
    app.post("/erp/veiculos/:placa/consultas", express.json(), async (req, res) => {
      const placa = String(req.params.placa).toUpperCase().replace(/[^A-Z0-9]/g, "");
      const pedidoRef = String(req.body.pedido_ref);            // ex.: "OS-2026-0412"
      const servico = req.body.servico ?? "consulta";
      const idem = `erp-${pedidoRef}-${servico}-t1`;
    
      // Registra ANTES de chamar a API: se o processo cair no meio, a chave fica
      // gravada e o reprocessamento repete o POST com a mesma chave (sem cobrar 2x).
      const { rows: [c] } = await db.query(
        `INSERT INTO consultas_zapcar (pedido_ref, servico, placa, idempotency_key)
         VALUES ($1, $2, $3, $4)
         ON CONFLICT (idempotency_key) DO UPDATE SET atualizado_em = now()
         RETURNING *`, [pedidoRef, servico, placa, idem]);
    
      if (c.zapcar_id) return res.status(202).json({ id: c.id, status: c.status });
    
      const resp = await zapcar("/v1/consultas", {
        method: "POST",
        headers: { "Content-Type": "application/json", "Idempotency-Key": idem },
        body: JSON.stringify({ servico, placa }),
      });
      const corpo = await resp.json().catch(() => ({}));
    
      if (resp.status === 201 || resp.status === 200) {
        await db.query(
          `UPDATE consultas_zapcar SET zapcar_id = $2, status = 'processando',
             valor_cobrado = $3, atualizado_em = now() WHERE id = $1`,
          [c.id, corpo.id, corpo.valor_cobrado ?? null]);
        return res.status(202).json({ id: c.id, status: "processando" });
      }
      // Recusado antes de existir (400/402/422…): nada foi cobrado.
      await db.query(
        `UPDATE consultas_zapcar SET status = 'erro', erro_codigo = $2, retryable = $3,
           atualizado_em = now() WHERE id = $1`,
        [c.id, corpo.codigo ?? `HTTP_${resp.status}`, corpo.retryable ?? null]);
      return res.status(422).json({ id: c.id, status: "erro", motivo: corpo.erro ?? "Falha ao criar a consulta" });
    });
    
    // 2. A tela consulta o andamento no SEU backend, nunca na ZapCar.
    app.get("/erp/consultas/:id", async (req, res) => {
      const { rows: [c] } = await db.query("SELECT * FROM consultas_zapcar WHERE id = $1", [req.params.id]);
      if (!c) return res.sendStatus(404);
      res.json({ status: c.status, erro_codigo: c.erro_codigo, resumo: c.resultado ? resumoParaTela(c.resultado) : null });
    });
    
    // 3. O PDF passa pelo seu backend (a chave não vai ao navegador). O Content-Type
    //    vem da ZapCar: alguns documentos são imagem.
    app.get("/erp/consultas/:id/documento", async (req, res) => {
      const { rows: [c] } = await db.query("SELECT zapcar_id, status FROM consultas_zapcar WHERE id = $1", [req.params.id]);
      if (!c || c.status !== "concluido") return res.sendStatus(404);
      const resp = await zapcar(`/v1/consultas/${c.zapcar_id}/pdf`);
      if (!resp.ok) return res.sendStatus(502);
      res.setHeader("Content-Type", resp.headers.get("content-type") ?? "application/octet-stream");
      res.setHeader("Content-Disposition", resp.headers.get("content-disposition") ?? "inline");
      res.send(Buffer.from(await resp.arrayBuffer()));
    });
    
    app.listen(3001);
  3. Passo 3: Acompanhe até o fim

    Com webhook, o evento query.completed dispara a atualização (ver o guia de webhooks). Sem webhook, ou como rede de segurança, um worker consulta as pendentes:
    Node.jsworker de acompanhamento
    // Worker de acompanhamento (se você não usa webhook, ou como reconciliação).
    // Roda a cada 5 s; respeita o teto de 5 min por consulta e o limite de requisições.
    async function acompanhar() {
      const { rows } = await db.query(
        `SELECT * FROM consultas_zapcar
          WHERE status = 'processando' AND zapcar_id IS NOT NULL
          ORDER BY atualizado_em LIMIT 20`);
      for (const c of rows) {
        const resp = await zapcar(`/v1/consultas/${c.zapcar_id}`);
        if (resp.status === 429) break;                      // espere o próximo ciclo
        if (!resp.ok) continue;
        const r = await resp.json();
        if (r.status === "processando") {
          await db.query("UPDATE consultas_zapcar SET atualizado_em = now() WHERE id = $1", [c.id]);
          continue;
        }
        await db.query(
          `UPDATE consultas_zapcar SET status = $2, erro_codigo = $3, retryable = $4,
             resultado = $5, atualizado_em = now() WHERE id = $1`,
          [c.id, r.status, r.erro_codigo ?? null, r.retryable ?? null,
           r.status === "concluido" ? JSON.stringify({ veiculo: r.veiculo ?? null, nao_verificado: r.nao_verificado ?? [], pdf_url: r.pdf_url }) : null]);
      }
    }
    setInterval(() => acompanhar().catch(console.error), 5000);
  4. Passo 4: Mostre o resultado na tela do ERP

    Node.jsdo veiculo para a tela
    // Traduz o bloco "veiculo" para a tela do ERP SEM transformar falta de
    // informação em "nada consta". null = não verificado, sempre.
    function resumoParaTela({ veiculo: v, nao_verificado = [] }) {
      if (!v) return { disponivel: false };
      const tri = (x) => (x === true ? "sim" : x === false ? "não" : "não verificado");
      const debitos = v.debitos ?? [];
      return {
        disponivel: true,
        placa: v.placa,
        veiculo: [v.marca, v.modelo].filter(Boolean).join(" "),
        ano: `${v.ano_fabricacao ?? "?"}/${v.ano_modelo ?? "?"}`,
        restricoes_ativas: (v.restricoes ?? []).filter((r) => r.ativa).map((r) => r.descricao ?? r.tipo),
        sinistro: tri(v.sinistro),
        recall: tri(v.recall),
        leilao: v.leilao == null ? "não verificado" : (v.leilao.consta || v.leilao.fotos > 0) ? "sim" : "não",
        debitos: debitos.map((d) => ({ tipo: d.tipo, valor: d.valor_informado ? d.valor_centavos / 100 : null })),
        // Com algum débito sem valor informado, o total é um PISO, não o devido.
        debitos_minimo: (v.debitos_total_centavos ?? 0) / 100,
        debito_sem_valor: debitos.some((d) => d.valor_informado === false),
        nao_verificado,
      };
    }

Nova tentativa depois de uma falha

Quando a linha termina em erro com retryable: true, crie uma linha nova com tentativa = 2 e chave erp-OS-2026-0412-consulta-t2. O valor da primeira já voltou ao saldo; a segunda é um débito novo. Com retryable false ou ausente, mostre o motivo ao usuário e deixe a decisão com ele.

Atenção:

Consultas feitas fora do ERP

Com webhook, o seu endpoint também recebe eventos de consultas feitas no portal ou no Telegram pela mesma conta. Procure o data.id na sua tabela e ignore o que não encontrar.
Dica:
Para homologar, rode o fluxo inteiro com a chave de teste e as placas TST0002 (gravame), TST0004 (débito sem valor) e TST0005 (sinistro e leilão): são os casos que mais exigem cuidado na tela.