# Webhook

Receba confirmações de entrega e mensagens dos contatos no seu sistema.

Cadastre a URL do seu endpoint no painel, em Integrações, e escolha os eventos. Fazemos um `POST` assinado para cada evento selecionado.

## Eventos disponíveis

| Evento | Dispara quando | Serve para |
| --- | --- | --- |
| contact.marketing\_opt\_out | O contato pede para parar promoções. | Registrar a saída sem depender do rótulo do botão. |
| template.status | A Meta comunica aprovação, rejeição, pausa ou outro estado. | Atualizar o catálogo e suspender modelos indisponíveis. |
| template.quality | A Meta informa mudança de qualidade do modelo. | Acompanhar a nota, incluindo a primeira leitura. |
| `message.status` | Uma mensagem enviada pela plataforma muda de status. | Confirmar entrega, leitura ou falha. |
| `message.received` | Uma mensagem do contato é recebida. | Processar a resposta e atualizar a janela de atendimento. |

> Escolha os eventos usados pela sua integração. Para receber respostas dos contatos, habilite também `message.received`; ele contém dados da conversa.

Os novos eventos são opcionais. Habilite-os em Integrações quando seu receptor estiver preparado; não alteramos as assinaturas já existentes. Uma entrega HTTP 2xx confirma que o receptor aceitou o evento, não que concluiu a operação. Use [o catálogo](https://patrociniotech.com/documentacao-api/templates.md) com `status=ALL` para reconciliar o estado atual dos modelos.

## Testar pelo painel

Em Integrações → Webhook → Testar um evento, escolha o evento e clique em Enviar teste. Os cinco tipos estão disponíveis conforme as assinaturas salvas. O webhook precisa estar ativo, sem alterações por salvar.

> Todo teste contém `data.test: true`, inclusive `contact.marketing_opt_out`. Valide primeiro a assinatura; depois, se o marcador for o booleano `true`, responda 2xx sem gravar o evento nem alterar cadastros. Faça isso antes das validações de telefone e das operações de negócio.

Os dados são fictícios: telefone composto por zeros, identificadores de exemplo e nenhum cadastro real consultado. Cada clique gera um `delivery_id` diferente, com prefixo `test_`; trate o identificador como texto. A assinatura e o cabeçalho adicional seguem a configuração salva.

JSON · teste de saída de promoções

```json
{
  "event": "contact.marketing_opt_out",
  "occurred_at": "2026-11-06T16:00:00Z",
  "delivery_id": "test_0123456789abcdef0123456789abcdef",
  "data": {
    "message_id": "wamid.TESTE",
    "to": "000000000000",
    "phone_number_id": "0",
    "occurred_at": "2026-11-06T16:00:00Z",
    "source": "whatsapp",
    "scope": "tenant",
    "marketing_allowed": false,
    "campaign_id": null,
    "interaction": {
      "type": "button_reply",
      "id": "TEST_MARKETING_OPT_OUT",
      "title": "Parar promoções"
    },
    "test": true
  }
}
```

O teste faz uma única tentativa, mostra o HTTP retornado e registra a ação na auditoria. Não entra na fila de entregas, não tem repetição automática e não altera contatos, consentimentos ou templates. Um 2xx confirma a recepção HTTP; não comprova o processamento de eventos reais, que não recebem esse marcador.

## Mudanças de templates

JSON · template.status

```json
{
  "event": "template.status",
  "occurred_at": "2026-11-06T16:00:00Z",
  "delivery_id": "2001",
  "data": {
    "waba_id": "123456789012345",
    "template_id": "234567890123456",
    "name": "pedido_recebido",
    "language": "pt_BR",
    "status": "APPROVED",
    "reason": "NONE"
  }
}
```

O evento `template.quality` mantém a identificação do modelo e traz `previous_quality` e `quality`, no lugar de status/reason. Campos indisponíveis podem ser nulos; aceite códigos futuros sem quebrar o receptor. A WABA identifica a conta, pois um template pode servir a vários números. O horário do envelope é o registro do evento na plataforma; não prova o instante exato da decisão da Meta.

## Saída de promoções

`message.received` preserva texto e acrescenta `interaction` para botões/listas e `consent` quando uma saída é reconhecida. Use `consent.action = marketing_opt_out`, em vez de comparar o texto “Parar promoções”. O evento opcional `contact.marketing_opt_out` comunica essa mesma ação com identificador da mensagem e contato. Não soma uma segunda recusa; deduplique a sua operação pelo contato/mensagem.

A recusa de marketing vale para os números da empresa. Não representa cancelamento dos avisos legítimos de pedidos. A estrutura e as regras de aceite estão em [Consentimento](https://patrociniotech.com/documentacao-api/consentimento.md).

## message.status

corpo do POST

```text
{
  "event": "message.status",
  "occurred_at": "2026-07-30T18:00:00Z",
  "delivery_id": "1841",
  "data": {
    "message_id": "wamid.HBgNNTUyMT...",
    "status": "delivered",
    "status_rank": 3,
    "to": "5521999999999",
    "error": null,
    "phone_number_id": "840747309123670",
    "client_ref": "pedido-8421",
    "campaign_id": null
  }
}
```

## message.received

O evento informa o conteúdo recebido, o contato e o número da empresa. Use esses dados para atualizar sua aplicação ou preparar uma resposta.

corpo do POST

```text
{
  "event": "message.received",
  "occurred_at": "2026-08-02T11:14:22Z",
  "delivery_id": "1842",
  "data": {
    "message_id": "wamid.HBgNNTUyMT...",
    "from": "5521999999999",
    "phone_number_id": "1234567890",
    "type": "text",
    "text": "Recebi, obrigado!",
    "media_id": null,
    "media_mime": null,
    "media_filename": null,
    "reply_to_message_id": "wamid.HBgNNTUyMT...",
    "contact_name": "Maria",
    "occurred_at": "2026-08-02T11:14:22Z",
    "window_expires_at": "2026-08-03T11:14:22Z"
  }
}
```

| Campo | O que é |
| --- | --- |
| from | Telefone do contato que enviou a mensagem. Em `message.status`, o telefone do destinatário aparece em `to`. |
| type | text, image, audio, video, document, sticker, location, contacts, button, interactive, unsupported. |
| interaction | Objeto com type, id e title para botão ou lista recebida; nulo nos demais casos. id preserva o payload do botão ou o identificador da opção. |
| consent | Quando reconhecemos uma saída de promoções: action, marketing\_allowed, scope e changed. Nulo quando não houve essa ação. Não deduza consentimento pelo texto da mensagem. |
| text | O texto, a legenda de uma mídia ou o rótulo de um botão/opção recebida. Nulo quando não há texto. O identificador da interação fica no campo interaction. |
| media\_id | Presente quando veio arquivo. Consulte a conversa no painel para acessar a mídia. O identificador não é uma URL pública nem garante disponibilidade permanente. |
| reply\_to\_message\_id | ID da mensagem citada pelo contato. Use-o para localizar o envio e a operação correspondente. |
| window\_expires\_at | Até quando a mensagem recebida permite resposta com texto livre. A janela não garante gratuidade. |

> Atenção: **window\_expires\_at** representa a janela associada à mensagem recebida. Eventos podem chegar fora de ordem: mantenha a maior validade por contato e número da empresa. Perto de expirar, confirme com [GET /window](https://patrociniotech.com/documentacao-api/janela.md). A cobrança segue as regras vigentes da Meta, separadamente dessa autorização de envio.

> Na coexistência, o eco de uma saída pelo celular pode vir sem a mensagem citada. Este evento descreve as mensagens recebidas dos contatos.

## Headers

| Header | O que é |
| --- | --- |
| X-PT-Timestamp | Timestamp Unix em segundos. Faz parte da assinatura e muda a cada tentativa. |
| X-PT-Signature | Prefixo `sha256=` mais o HMAC em hexadecimal minúsculo. |
| X-PT-Delivery-Id | Id desta entrega. Repete nas retentativas do mesmo evento. |
| X-PT-Attempt | Número da tentativa, começando em 1. |
| X-PT-Event | Um dos cinco eventos disponíveis acima. Confira também event no corpo assinado. |

## Conferir a assinatura

A assinatura é o HMAC-SHA256 de `"timestamp" + "." + corpo cru`, em hexadecimal minúsculo, com o prefixo `sha256=`. O segredo aparece no painel, em Integrações.

> Atenção: Calcule a assinatura sobre os **bytes originais** do corpo. Converter o JSON e serializá-lo novamente pode alterar o conteúdo usado no HMAC.

Node.js (Express)

```javascript
const crypto = require("crypto");

// Guarde o corpo CRU. Sem isto o express.json() já consumiu o stream.
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));

function assinaturaValida(req, segredo) {
  const ts = req.get("X-PT-Timestamp");
  const assinada = req.get("X-PT-Signature") || "";
  if (!segredo || !Buffer.isBuffer(req.rawBody)) return false;
  if (!/^\d{1,12}$/.test(ts || "") || !/^sha256=[a-f0-9]{64}$/.test(assinada)) return false;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const esperada =
    "sha256=" +
    crypto.createHmac("sha256", segredo).update(`${ts}.`).update(req.rawBody).digest("hex");

  // Comparação em tempo constante: comparar com === vaza, pelo tempo, o quanto você acertou.
  const a = Buffer.from(assinada);
  const b = Buffer.from(esperada);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;

  return true;
}

app.post("/webhooks/patrociniotech", async (req, res) => {
  if (!assinaturaValida(req, process.env.PT_WEBHOOK_SECRET)) return res.sendStatus(401);
  if (req.body?.data?.test === true) return res.sendStatus(200);
  const { delivery_id, event } = req.body;
  const eventos = ["message.status", "message.received", "contact.marketing_opt_out",
    "template.status", "template.quality"];
  if (!delivery_id || !eventos.includes(event)) {
    return res.sendStatus(400);
  }
  try {
    // Implemente com armazenamento durável e chave única por integração + delivery_id.
    // A operação precisa confirmar a gravação ou reconhecer uma duplicata já gravada.
    await gravarNaFilaDuravel(req.body);
    return res.sendStatus(200);
  } catch {
    return res.sendStatus(503); // permite nova tentativa se a gravação falhou
  }
});
```

PHP

```php
$corpoCru = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_PT_TIMESTAMP'] ?? '';
$assinada = $_SERVER['HTTP_X_PT_SIGNATURE'] ?? '';

$formatoValido = preg_match('/^[0-9]{1,12}$/D', $ts)
    && preg_match('/^sha256=[a-f0-9]{64}$/D', $assinada);
if (!$segredo || !$formatoValido || abs(time() - (int) $ts) > 300) {
    http_response_code(401);
    exit;
}

$esperada = 'sha256=' . hash_hmac('sha256', $ts . '.' . $corpoCru, $segredo);
if (!hash_equals($esperada, $assinada)) {
    http_response_code(401);
    exit;
}

try {
    $evento = json_decode($corpoCru, true, 512, JSON_THROW_ON_ERROR);
    if (($evento['data']['test'] ?? false) === true) {
        http_response_code(200);
        exit; // teste assinado: não persistir nem executar operações
    }
    // Implemente com chave única por integração + delivery_id e confirme a gravação.
    gravarNaFilaDuravel($evento);
    http_response_code(200);
} catch (JsonException $erro) {
    http_response_code(400);
} catch (Throwable $erro) {
    http_response_code(503);
}
```

> Cada tentativa recebe uma nova assinatura e um timestamp atual. A tolerância de cinco minutos do exemplo vale para a tentativa HTTP, não para a idade do evento.

## Campos de message.status

| Campo | O que é |
| --- | --- |
| message\_id | O mesmo id devolvido no envio. |
| status | sent, delivered, read ou failed. |
| status\_rank | sent=2, delivered=3, read=4. Ignore regressões entre esses estados. failed=1 é um desfecho separado: pode substituir sent, mas não delivered ou read; não aplique uma comparação numérica simples para falhas. |
| to | Número do destinatário. Mesmo nome do campo no envio. |
| error | Objeto `{code, message, reason}` só quando o status é failed; nulo nos outros. `reason` é o nosso código estável, `code` é o número da Meta. |
| client\_ref | Referência informada no envio. Pode ser nula; combine-a com message\_id para localizar a operação. |
| campaign\_id | Preenchido quando a mensagem saiu de uma campanha do painel. |
| delivery\_id | Id desta entrega. Repete nas retentativas, então serve de chave de idempotência do seu lado. |
| occurred\_at | No envelope: quando o evento entrou na fila. Permanece fixo nas tentativas. Em message.received, data.occurred\_at informa o horário da mensagem recebido da Meta. |

## Confirmar o recebimento do evento

Para eventos reais, qualquer coisa fora de `2xx` entra na fila de retentativa. Se o seu processamento é demorado, confirme primeiro a gravação numa fila durável, responda e processe depois. Há limite de 10 segundos por tentativa e redirecionamentos HTTP não são seguidos.

> Atenção: Uma resposta `2xx` confirma que seu endpoint aceitou o evento. A plataforma não interpreta o corpo para decidir se deve repetir. Para eventos reais, não responda sucesso antes de persistir: se o processo cair, o evento ainda precisa estar disponível para seu worker.

O mesmo `delivery_id` pode chegar mais de uma vez. Use uma chave única por integração e entrega para não repetir efeitos já aplicados.

## Se o seu endpoint cair

Eventos habilitados são persistidos numa fila. Após a tentativa inicial, repetimos em 30s, 2min, 10min, 1h, 6h, 24h, com variação de até 20% para distribuir a carga: sete tentativas automáticas e cerca de 31 horas de espera acumulada. O que esgota as tentativas fica guardado esperando reenvio, que você dispara pelo painel ou pela [API de entregas](https://patrociniotech.com/documentacao-api/entregas.md).

---

Fonte: https://patrociniotech.com/documentacao-api/webhook
Índice para IA: https://patrociniotech.com/documentacao-api/llms.txt
