> ## Documentation Index
> Fetch the complete documentation index at: https://docs.octasend.com.br/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook de status

> Receba um POST assinado a cada mudança de status das mensagens enviadas pela API: cadastro da URL, payload, verificação da assinatura, tentativas e desativação automática

Em vez de consultar o histórico em [Consultar logs](https://docs.octasend.com.br/api-reference/consultar-logs.md) para saber o que aconteceu com cada mensagem, cadastre uma URL e receba um `POST` a cada mudança de status das mensagens que **você enviou pela API** ([Enviar mensagens](https://docs.octasend.com.br/api-reference/enviar-mensagens.md)).

## Cadastrar

No painel, em **Canais → Configuração geral**, informe a URL do seu endpoint e ative o webhook. É preciso ter a permissão de configurar o webhook de status no seu perfil de acesso.

* A URL precisa usar **HTTPS**, sem usuário ou senha.
* Endereços internos são recusados: `localhost`, IPs de rede privada ou reservada e nomes sem domínio público. A checagem acontece ao salvar e **de novo a cada envio**.
* Na primeira vez que você salva, a tela mostra o **segredo** (`whsec_...`) **uma única vez**. Guarde-o: depois de fechar o aviso, ele não aparece de novo. Ao **regenerar** o segredo, o antigo deixa de valer na hora e o novo também aparece uma única vez.
* O botão **Enviar teste** envia um evento `webhook.test` à URL **já salva** (sem repetir em caso de falha) e mostra o status HTTP e o tempo de resposta. Com uma edição de URL ainda não salva, o botão fica desabilitado: salve primeiro.

## Ligar o callback à sua mensagem

Mande um `external_id` (texto, até 255 caracteres) no `send-message` — por exemplo, o id do pedido no seu sistema. Ele volta em todo callback daquela mensagem. No lote, cada item de `messages` aceita o seu. Cada item de `results` do envio também devolve o `message_id`, o mesmo `data.message_id` do callback.

Quando o envio é adiado pelo [limite por minuto](https://docs.octasend.com.br/api-reference/enviar-mensagens.md#limite-de-envio-por-minuto), a resposta vem com `deferred: true` e `status: "queued"`, **sem `message_id`** (o registro da mensagem só nasce quando ela é reprocessada), e devolve o `external_id` que você mandou. Os callbacks começam quando a mensagem é de fato processada: case-os pelo `external_id`.

## O callback

`POST` na sua URL, com `Content-Type: application/json`, `User-Agent: OctaSend-Webhook/1.0` e o cabeçalho `X-OctaSend-Signature`:

```json theme={null}
{
  "event_id": "3f6c1b0e-5d2a-4a8e-9c1f-0b7d2e4a6c11",
  "type": "message.status",
  "created_at": "2026-10-29T14:03:22.418Z",
  "data": {
    "message_id": "7b0c2f4e-1d3a-4c55-9a52-5f1d7e8a9b10",
    "external_id": "pedido-12345",
    "provider_message_id": "wamid.xxx...",
    "channel": "whatsapp",
    "status": "delivered",
    "occurred_at": "2026-10-29T14:03:22.418Z",
    "error_message": null,
    "failure_code": null
  }
}
```

| Campo | Descrição |
| - | - |
| `event_id` | Identificador único do evento (UUID). Use para deduplicar |
| `type` | `message.status`. O teste do painel envia `webhook.test` |
| `created_at` | Quando o evento foi criado |
| `data.message_id` | Id da mensagem na OctaSend (o mesmo `message_id` do `send-message` e o `id` do histórico) |
| `data.external_id` | O `external_id` que você mandou; `null` se não mandou |
| `data.provider_message_id` | Id da mensagem no provedor (ex.: `wamid...` no WhatsApp); `null` se ainda não houver |
| `data.channel` | Canal da mensagem (ex.: `email`, `whatsapp`) |
| `data.status` | `queued`, `sent`, `delivered`, `read` ou `failed` |
| `data.occurred_at` | Quando a mudança de status foi registrada |
| `data.error_message` | Motivo em texto, quando o status é `failed`; `null` nos demais. É para exibir ou registrar, e pode mudar |
| `data.failure_code` | Código estável do motivo quando o status é `failed` (veja [Códigos de falha](#códigos-de-falha-failure_code)); `null` nos demais. Use este campo, não o texto de `error_message`, para decidir o que fazer |

Cada mudança de status gera um evento. `delivered` e `read` só chegam quando o provedor confirma (o WhatsApp, por exemplo). O evento `webhook.test` tem o mesmo formato, com `external_id: "pedido-exemplo-123"` e `status: "delivered"`; ignore-o no processamento de mensagens.

Exemplo de callback de uma mensagem que falhou (WhatsApp para um número sem conta):

```json theme={null}
{
  "event_id": "9a1d4c2e-7b3f-4e60-8d15-2c6e0f9b3a47",
  "type": "message.status",
  "created_at": "2026-10-29T14:05:10.102Z",
  "data": {
    "message_id": "7b0c2f4e-1d3a-4c55-9a52-5f1d7e8a9b10",
    "external_id": "pedido-12345",
    "provider_message_id": null,
    "channel": "whatsapp",
    "status": "failed",
    "occurred_at": "2026-10-29T14:05:10.102Z",
    "error_message": "Número não está registrado no WhatsApp",
    "failure_code": "recipient_not_on_channel"
  }
}
```

Se uma falha ainda não foi classificada, `failure_code` vem como `unknown`.

## Códigos de falha (`failure_code`)

Toda mensagem que falha (`failed`) ou é pulada (`skipped`) leva um `failure_code`: um código estável, em inglês e em `snake_case`, que diz **por que** ela não saiu. Use o código, e não o texto de `error_message`, para decidir o que fazer: o texto é para exibir ou registrar e pode mudar.

Falhas registradas antes de este campo existir podem vir com `failure_code` nulo na leitura pela API; os webhooks e as falhas novas sempre trazem o código.

Onde ele aparece: no callback do webhook de status (`data.failure_code`, preenchido só quando `status` é `failed`; `null` nos demais), no resultado de cada canal do `send-message` e na leitura do histórico (`query-message-logs`, `get_message` e `list_messages`).

| Código | Quando acontece | O que fazer |
| - | - | - |
| `invalid_recipient` | Telefone ou e-mail ausente ou em formato inválido; também contato removido ou inativo | Corrija o cadastro do contato e envie de novo. Não reenvie como está |
| `recipient_not_on_channel` | Número sem conta no WhatsApp | Confirme o número com o contato; não reenvie até corrigir |
| `recipient_opted_out` | O contato se descadastrou do canal e o envio pediu `respect_opt_out: true` (a mensagem fica `skipped`) | Não reenvie |
| `channel_disconnected` | A conexão do canal caiu, por exemplo o WhatsApp desconectado ou a conexão com a Meta expirada | Reconecte o canal no painel e reenvie. Pode reenviar automaticamente depois da reconexão |
| `channel_not_configured` | O canal não tem configuração ou credenciais válidas | Complete a configuração do canal no painel e reenvie |
| `channel_disabled` | O canal está desabilitado na sua conta | Ative o canal no painel, se quiser enviar por ele |
| `outside_messaging_window` | O WhatsApp só aceita texto livre até 24 h depois da última mensagem do contato; fora disso, só template aprovado | Envie um template aprovado, ou espere o contato escrever de novo |
| `rate_limited` | O provedor do canal limitou o ritmo de envio | Tente de novo depois, com espera crescente. Pode reenviar automaticamente |
| `quota_exceeded` | A conta bateu o limite diário de mensagens ou a cota mensal do plano | Aumente o limite ou o plano, ou espere a virada do período, e reenvie |
| `empty_content` | A mensagem ficou sem conteúdo | Preencha o corpo da mensagem e envie de novo |
| `invalid_media` | A mídia anexa é inválida ou não pôde ser usada | Confira o link, o formato e o tamanho do arquivo e envie de novo |
| `template_not_found` | O template escolhido não existe ou não foi encontrado | Confira o template e envie de novo |
| `template_rejected` | A Meta rejeitou, reprovou ou pausou o template | Corrija ou reaprove o template na Meta, ou use outro |
| `template_unsupported_for_connection` | Template da Meta usado numa conexão de envio que não o aceita | Envie por uma conexão compatível com templates da Meta |
| `provider_rejected` | O provedor recusou a mensagem por um motivo que não cai nos outros códigos | Veja `error_message`. Corrija o que ele indicar ou fale com o suporte |
| `provider_unavailable` | O provedor ou a integração está fora do ar, ou a autenticação com ele falhou | Tente de novo depois. Pode reenviar automaticamente. Se persistir, fale com o suporte |
| `delivery_failed` | O provedor aceitou a mensagem, mas ela não foi entregue | Confirme o dado do destinatário e tente de novo mais tarde |
| `unknown` | Ainda não conseguimos classificar o motivo | Veja `error_message`. Se acontecer com frequência, fale com o suporte |

**Reenvio:** é seguro automatizar o reenvio de `rate_limited`, `provider_unavailable` e `channel_disconnected` (este só depois de reconectar o canal). Nunca reenvie `recipient_opted_out`, `recipient_not_on_channel` e `invalid_recipient`: o resultado será o mesmo.

**Códigos novos:** o conjunto pode crescer com o tempo. Trate qualquer valor que você não conheça como `unknown`, sem quebrar o seu processamento.

**Não confunda com `error_code`:** o `error_code` é um campo mais antigo, que aparece só em algumas respostas do `send-message` (por exemplo `daily_limit_reached`, `plan_quota_reached`, `opt_out_check_failed` e `channel_check_failed`) e, na leitura do histórico, pode trazer o código bruto do provedor. Ele não mudou. O `failure_code` é o campo estável, com o catálogo acima.

## Verificar a assinatura

```text theme={null}
X-OctaSend-Signature: t=1761746602,v1=5b1f...e9
```

`t` é o timestamp Unix (segundos) do envio e `v1` é o HMAC-SHA256, em hexadecimal, da string `"<t>.<corpo>"`, usando o seu segredo como chave. O `corpo` é o JSON **exatamente como chegou**, em bytes: calcule sobre o corpo bruto, antes de qualquer `JSON.parse`. Compare em tempo constante e rejeite timestamps fora de uma tolerância (5 minutos nos exemplos) para barrar replay.

```js Node.js (Express) theme={null}
const crypto = require('crypto');
const express = require('express');

const SEGREDO = process.env.OCTASEND_WEBHOOK_SECRET; // whsec_...
const TOLERANCIA_S = 300;

function assinaturaValida(rawBody, header) {
  const partes = Object.fromEntries(
    (header || '').split(',').map((p) => p.split(/=(.*)/s).slice(0, 2))
  );
  const t = Number(partes.t);
  if (!t || !partes.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > TOLERANCIA_S) return false;
  const esperado = crypto
    .createHmac('sha256', SEGREDO)
    .update(`${t}.${rawBody}`)
    .digest('hex');
  const a = Buffer.from(esperado);
  const b = Buffer.from(partes.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();
app.post('/webhooks/octasend', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8');
  if (!assinaturaValida(rawBody, req.get('X-OctaSend-Signature'))) {
    return res.sendStatus(401);
  }
  const evento = JSON.parse(rawBody);
  // deduplique por evento.event_id e processe evento.data
  res.sendStatus(200);
});
```

```python Python (Flask) theme={null}
import hashlib
import hmac
import os
import time

from flask import Flask, request

SEGREDO = os.environ["OCTASEND_WEBHOOK_SECRET"]  # whsec_...
TOLERANCIA_S = 300

app = Flask(__name__)


def assinatura_valida(raw_body: bytes, header: str) -> bool:
    partes = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
    try:
        t = int(partes["t"])
        v1 = partes["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > TOLERANCIA_S:
        return False
    esperado = hmac.new(
        SEGREDO.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(esperado, v1)


@app.post("/webhooks/octasend")
def octasend():
    if not assinatura_valida(request.get_data(), request.headers.get("X-OctaSend-Signature")):
        return "", 401
    evento = request.get_json()
    # deduplique por evento["event_id"] e processe evento["data"]
    return "", 200
```

## Resposta esperada

Responda com **qualquer 2xx em até 10 segundos**. Qualquer outra coisa conta como falha: status fora de 2xx, erro de conexão, tempo esgotado ou **redirecionamento** (3xx). A OctaSend não segue redirects, então cadastre a URL final.

## Tentativas e desativação

* Cada evento tem até **5 tentativas**. Depois de uma falha, a próxima vem após **1, 5, 15 e 30 minutos** (a verificação roda a cada minuto, então os intervalos podem variar em cerca de 1 minuto).
* Se as 5 tentativas falharem, o evento fica como **esgotado**. Ele pode ser **reenviado manualmente** na tela de Configuração geral, com o mesmo `event_id`.
* Depois de **20 eventos esgotados seguidos** (qualquer entrega com sucesso zera a contagem), o webhook é **desativado automaticamente** e o dono e os administradores ativos da conta (inclusive o perfil Master) recebem um aviso no sino do painel. Depois de corrigir o endpoint, reative-o na tela; a contagem recomeça do zero.
* Enquanto o webhook estiver desativado (por você ou automaticamente), as mudanças de status novas **não geram evento**. Os eventos que já estavam na fila esperam e são enviados quando você reativar; um evento com mais de 30 dias é encerrado sem envio.
* O histórico de envios e tentativas da tela é guardado por **30 dias**.

## Boas práticas ao receber

* **Entrega pelo menos uma vez:** o mesmo evento pode chegar mais de uma vez (resposta perdida, reenvio manual). O `event_id` é o mesmo em todas as tentativas: guarde os já processados e ignore repetidos.
* **A ordem não é garantida:** um `delivered` pode chegar antes do `sent`. Use `data.occurred_at` para decidir o estado mais recente e não sobrescreva um estado novo por um antigo.
* **Responda rápido:** devolva 2xx assim que validar e guardar o evento, e processe o resto depois.
* **Só mensagens enviadas pela API são notificadas.** Envios do painel, campanhas e automações não geram callback.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.