Webhook da campanha
O webhook é o canal server-to-server da campanha: a Zarv entrega, por HTTPS assinado, cada etapa concluída e a decisão final de um cadastro.
Ele existe porque os callbacks do navegador não são confiáveis para decisão de negócio. O onDecision do SDK chega por postMessage de um iframe, sem assinatura — qualquer pessoa com o console aberto forja um approved. Use os callbacks para mover a interface; use o webhook para liberar acesso, aprovar uma locação, criar um contrato.
Há ainda um segundo motivo, mais prosaico: a decisão pode nunca chegar ao navegador. Ela vem de um polling que roda dentro do iframe, então se o visitante fecha a aba — ou se você desmonta o embed assim que a última etapa termina — o onDecision simplesmente não dispara. O webhook chega de qualquer forma.
Assinatura da campanha
Cada campanha tem suas próprias assinaturas de webhook, configuradas no admin Zarv. Uma assinatura define:
| Campo | Descrição |
|---|---|
url | Seu endpoint. HTTPS obrigatório; endereços privados/internos são recusados. |
events | Lista de eventos que você quer. Vazio = todos. |
includeLeadData | false por padrão. Ligado, os eventos terminais carregam um bloco lead com PII. |
includeFiles | false por padrão. Ligado, os eventos terminais carregam files[] com URLs assinadas. |
O signingSecret é exibido uma única vez, na criação e a cada rotação — depois disso só os últimos 4 caracteres são legíveis. Guarde-o no seu cofre de segredos na hora em que aparecer.
Ligue includeLeadData e includeFiles apenas se o seu endpoint estiver adequadamente protegido: eles transportam dados pessoais e links para os documentos capturados.
Eventos
Por etapa — emitidos conforme o visitante conclui cada tela:
| Evento | Etapa |
|---|---|
lead.basic.completed | Dados básicos |
lead.email.completed | E-mail verificado |
lead.phone.completed | Telefone verificado |
lead.address.completed | Endereço |
lead.custom.completed | Campos personalizados |
lead.cnh.completed | CNH enviada |
lead.face.completed | Facial / liveness |
Para quem monta as telas isoladas, esses eventos são o equivalente server-side do onComplete de cada tela — e, ao contrário dele, confiáveis.
Terminais — emitidos quando o cadastro chega a um estado final:
| Evento | Significado |
|---|---|
lead.submitted | Cadastro enviado |
lead.approved | Aprovado |
lead.rejected | Reprovado |
lead.completed | O cadastro chegou ao fim do funnel |
Os blocos lead e files só aparecem em eventos terminais, e só com o respectivo toggle ligado.
A decisão pode chegar em duas fases
Nem todo cadastro é decidido na hora. Quando a análise automática não conclui — ou quando a campanha está com o autopilot desligado, caso em que nenhum cadastro é decidido automaticamente —, o lead vai para revisão humana, e a decisão chega depois, num segundo evento.
| Desfecho | Eventos, em ordem |
|---|---|
| Aprovado automaticamente | lead.approved → lead.completed |
| Reprovado automaticamente | lead.rejected → lead.completed |
| Enviado para revisão humana | lead.completed → (mais tarde) lead.approved ou lead.rejected |
Duas consequências práticas, e é aqui que integrações costumam errar:
lead.completedsozinho não quer dizer aprovado. Se ele chegar sem umlead.approved/lead.rejectedantes, o cadastro está pendente de análise humana — trate como "em análise", não como desfecho. O campoapprovedvem ausente exatamente nesse caso.- A decisão humana não repete o
lead.completed. Ela chega sozinha, minutos ou dias depois. Se o seu handler só reage alead.completed, você nunca verá o resultado dessas análises.
Por isso o gatilho de negócio deve ser lead.approved / lead.rejected, e não lead.completed.
Correlacionar com o seu registro
O payload traz o externalId — a sua própria referência, anexada ao lead pelo identify do SDK:
Zarv("smartflow", {
action: "identify",
leadId,
externalId: "PEDIDO-00042",
});É por ele que o seu backend reconhece de quem é o webhook, sem precisar confiar em nada que veio do navegador. Anexe o externalId logo na primeira tela, dentro do onSession.
Reconciliação: consultar pelo seu identificador
O webhook é o canal de push. Para o pull — reprocessar uma entrega que o seu endpoint perdeu, conferir um lote, ou responder "e aquele pedido, deu no quê?" — consulte o lead pelo mesmo externalId:
curl -G "https://services.zarv.com/api/v1/signup/leads" \
--data-urlencode "externalId=PEDIDO-00042" \
-H "Authorization: Bearer <accessToken>"A autenticação é a mesma OAuth2 do restante da API, e o token precisa da role id-list.
A busca é uma igualdade exata: o externalId é uma string opaca sua, que a Zarv nunca normaliza — não há match por prefixo, por parte, nem case-insensitive. Mandar o valor vazio não filtra nada (devolve a lista inteira), em vez de devolver os leads sem referência.
Trate isso como rede de segurança, não como substituto do webhook: consultar em laço é mais lento, mais caro e sempre atrás do evento.
Cabeçalhos de entrega
User-Agent: zarv-smartflow-webhook/2
X-Zarv-Event: <nome-do-evento>
X-Zarv-Delivery: <uuid-da-entrega>
X-Zarv-Attempt: <número-da-tentativa, começando em 1>
X-Zarv-Signature: sha256=<hmac-hex>Verificar a assinatura
O HMAC-SHA256 é calculado sobre o corpo cru da requisição, antes de qualquer parse de JSON. Se o seu framework já desserializou o corpo, você precisa do buffer original — reserializar não reproduz os mesmos bytes.
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func verifyWebhookSignature(secret, rawBody []byte, sigHeader string) bool {
mac := hmac.New(sha256.New, secret)
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sigHeader))
}import crypto from "node:crypto";
function verifyWebhookSignature(secret, rawBody, sigHeader) {
const mac = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const expected = `sha256=${mac}`;
const a = Buffer.from(expected);
const b = Buffer.from(sigHeader ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: capture o corpo cru antes do parser de JSON.
app.use("/webhooks/zarv", express.raw({ type: "application/json" }));Compare sempre em tempo constante (hmac.Equal / timingSafeEqual), nunca com ==.
A assinatura não inclui timestamp, então não há janela de replay embutida. Trate o X-Zarv-Delivery como chave de idempotência: registre os ids já processados e ignore repetições.
Payloads
Fino (todos os eventos):
{
"event": "lead.phone.completed",
"leadId": "lead_abc",
"campaignId": "camp_xyz",
"workspaceId": "ws_123",
"externalId": "PEDIDO-00042",
"step": "phone",
"status": "pending",
"score": 0,
"occurredAt": "2026-06-11T12:00:00Z"
}Terminal enriquecido (includeLeadData e includeFiles ligados):
{
"event": "lead.approved",
"leadId": "lead_abc",
"campaignId": "camp_xyz",
"workspaceId": "ws_123",
"externalId": "PEDIDO-00042",
"status": "approved",
"approved": true,
"score": 80,
"occurredAt": "2026-06-11T12:00:00Z",
"lead": {
"nationalId": "12345678901",
"fullName": "Jane Doe",
"birthDate": "1990-01-01",
"email": "[email protected]",
"phone": "+5511900000000",
"phoneIsWhatsApp": true,
"address": { "street": "Rua Exemplo", "number": "42", "zip": "01310-100", "city": "São Paulo", "state": "SP", "country": "BR" },
"customFields": [{ "id": "field_1", "type": "text", "label": "Empresa", "value": "Acme" }],
"metadata": { "utm_source": "google" }
},
"files": [
{ "type": "selfie", "fileName": "selfie.jpeg", "contentType": "image/jpeg", "url": "https://storage.googleapis.com/...", "expiresAt": "2026-06-12T12:00:00Z" },
{ "type": "cnh", "fileName": "cnh.jpeg", "contentType": "image/jpeg", "url": "https://storage.googleapis.com/...", "expiresAt": "2026-06-12T12:00:00Z" }
]
}Sobre files[]:
- Tipos:
selfie,cnh,liveness-smile,liveness-blink,liveness-turn-left,liveness-turn-right,profile, ecustom:<fieldId>para campos personalizados do tipo arquivo. - As URLs assinadas do GCS expiram em 24h (
expiresAt) e são geradas a cada tentativa — uma retentativa sempre entrega links válidos. Baixe os arquivos ao receber; não guarde a URL. - O
profileusa URL pública estável de CDN e não temexpiresAt. scoreestá sempre presente, inclusive quando é0.
Retentativas
| Resposta sua | O que acontece |
|---|---|
2xx | Entrega confirmada; o contador de falhas zera. |
408, 429, 5xx | Retentativa, até 6 tentativas, com backoff crescente. |
Outros 4xx | Falha permanente, sem retentativa. |
3xx | Redirecionamentos são recusados (falha permanente). |
Após 30 falhas consecutivas a assinatura é desativada automaticamente. Reative-a no admin depois de corrigir o endpoint.
Boas práticas
- Dispare o seu efeito de negócio em
lead.approved/lead.rejected, nunca emlead.completed— veja A decisão pode chegar em duas fases. - Responda
2xxrápido e processe de forma assíncrona: o timeout de entrega é de 10 segundos. - Implemente idempotência por
X-Zarv-Delivery— retentativas e entregas duplicadas acontecem. - Verifique a assinatura antes de fazer qualquer parse ou side effect.
- Guarde os payloads recebidos para auditoria.
- Seu endpoint precisa ser público na internet: endereços privados são recusados no momento da entrega.