Receber resultados por webhook
Receber o evento, validar a assinatura, deduplicar e atualizar o pedido sem polling.
Receber o aviso de que a consulta terminou, confirmar que ele veio da ZapCar, não processar o mesmo evento duas vezes e atualizar o pedido no seu sistema. O guia continua o guia do ERP (mesma tabela consultas_zapcar), mas vale para qualquer sistema.
O que o receptor precisa fazer
- Validar a assinatura HMAC sobre o corpo bruto e recusar timestamp com mais de 5 minutos.
- Deduplicar pelo id do evento: reentregas trazem o mesmo id.
- Responder 2xx em menos de 10 segundos e processar depois.
- Buscar o resultado com GET /v1/consultas/{id}: o evento não traz o dado.
- Atualizar o pedido de forma idempotente e sem depender da ordem de chegada.
- Manter uma reconciliação: se a entrega esgotar as tentativas, o resultado continua disponível no GET.
Passo a passo
Passo 1: Cadastre o endpoint e guarde o secret
Em Conta › Integrações, aba Webhooks: URL HTTPS pública, eventosquery.completedequery.failed. Guarde owhsec_…emZAPCAR_WEBHOOK_SECRET.Passo 2: Crie a tabela de eventos processados
SQLPostgreSQL -- Eventos já processados: a deduplicação é pelo id do EVENTO. CREATE TABLE zapcar_eventos ( event_id UUID PRIMARY KEY, tipo TEXT NOT NULL, recebido_em TIMESTAMPTZ NOT NULL DEFAULT now() );Passo 3: Implemente o receptor
Node.jsreceptor de webhooks // Receptor de webhooks (Node.js 18+, Express, pg). import crypto from "node:crypto"; 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 SECRET = process.env.ZAPCAR_WEBHOOK_SECRET; // whsec_… const TOLERANCIA_S = 300; function assinaturaValida(cabecalho, corpoBruto) { const t = /t=(\d+)/.exec(cabecalho ?? "")?.[1]; const v1 = /v1=([a-f0-9]+)/.exec(cabecalho ?? "")?.[1]; if (!t || !v1) return false; if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCIA_S) return false; // replay const esperado = crypto.createHmac("sha256", SECRET).update(`${t}.`).update(corpoBruto).digest("hex"); const a = Buffer.from(esperado), b = Buffer.from(v1); return a.length === b.length && crypto.timingSafeEqual(a, b); } // express.raw: a assinatura é sobre os BYTES; req.body chega como Buffer. app.post("/webhooks/zapcar", express.raw({ type: "application/json", limit: "64kb" }), async (req, res) => { if (!assinaturaValida(req.get("X-ZapCar-Signature"), req.body)) return res.sendStatus(401); const evento = JSON.parse(req.body.toString("utf8")); // Deduplicação: um retry reenvia o MESMO id de evento. const { rowCount } = await db.query( "INSERT INTO zapcar_eventos (event_id, tipo) VALUES ($1, $2) ON CONFLICT DO NOTHING", [evento.id, evento.type]); res.sendStatus(200); // responda em menos de 10 s if (rowCount === 0) return; // já processado // Processa DEPOIS de responder (em produção, prefira uma fila). processar(evento).catch((e) => console.error("webhook", evento.id, e)); }); async function processar(evento) { if (evento.type !== "query.completed" && evento.type !== "query.failed") return; const zapcarId = evento.data.id; // Só interessa o que o SEU sistema pediu (o webhook também recebe consultas // feitas no portal ou no Telegram pela mesma conta). const { rows: [c] } = await db.query("SELECT id FROM consultas_zapcar WHERE zapcar_id = $1", [zapcarId]); if (!c) return; // O evento não traz o resultado: busque com a sua chave. const resp = await fetch(`${BASE}/v1/consultas/${zapcarId}`, { headers: { Authorization: `Bearer ${process.env.ZAPCAR_API_KEY}` }, signal: AbortSignal.timeout(30_000), }); if (!resp.ok) throw new Error(`GET ${resp.status}`); // a reconciliação pega depois const r = await resp.json(); if (r.status === "processando") return; // fora de ordem: o GET manda // Atualização idempotente: aplicar duas vezes dá o mesmo resultado. await db.query( `UPDATE consultas_zapcar SET status = $2, erro_codigo = $3, retryable = $4, resultado = $5, atualizado_em = now() WHERE id = $1 AND status = 'processando'`, [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 ?? [] }) : null]); } app.listen(3001);Passo 4: Teste antes de ir ao ar
Gere um evento assinado no simulador e dispare contra o seu ambiente local com o cURL gerado. Depois, use o botão Testar do portal (webhook.test) contra a URL pública. Consultas do sandbox não disparamquery.*.
Atenção:
Não confie só no webhook
Esgotadas as 6 tentativas (cerca de 5 minutos), a entrega não é refeita. Mantenha o worker de acompanhamento do guia do ERP rodando com intervalo maior (por exemplo, a cada minuto) para as consultas que seguem em
processando há mais tempo do que o esperado.Dica:
Detalhes de cabeçalhos, política de reentrega e ordem de entrega estão na referência de webhooks.