---
url: https://developers.zarv.com/sdk/webhooks.md
description: >-
  Receba no seu servidor cada etapa e a decisão final do cadastro, por HTTPS
  assinado com HMAC-SHA256. Verificação, idempotência e retentativas.
---

# 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.completed` sozinho não quer dizer aprovado.** Se ele chegar sem um
  `lead.approved`/`lead.rejected` antes, o cadastro está **pendente de análise
  humana** — trate como "em análise", não como desfecho. O campo `approved`
  vem 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 a `lead.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:

```js
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`](/sdk/smartflow#varias-telas-no-seu-formulario).

## 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`:

```sh
curl -G "https://services.zarv.com/api/v1/signup/leads" \
  --data-urlencode "externalId=PEDIDO-00042" \
  -H "Authorization: Bearer <accessToken>"
```

A autenticação é a mesma [autenticação Bearer JWT do restante da API](/setup#autenticacao), 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.

::: code-group

```go [Go]
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))
}
```

```js [Node]
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):

```json
{
  "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):

```json
{
  "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": "jane.doe@example.com",
    "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`, e
  `custom:<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 `profile` usa URL pública estável de CDN e não tem `expiresAt`.
* `score` está 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
  em `lead.completed` — veja [A decisão pode chegar em duas fases](#a-decisao-pode-chegar-em-duas-fases).
* Responda `2xx` rá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.
