Webhooks

O formato único de webhook da Asender (v1): cabeçalhos, assinatura, verificadores em Node, Python, Go e PHP, cadastro por emissor e API /v1/webhooks, tentativas e idempotência.

Webhooks de saída

Este guia descreve o que o Asender envia para o seu servidor por HTTP: o formato de cada chamada, como conferir a assinatura, o que acontece quando o seu endpoint falha e como tornar o seu receptor seguro. Ele descreve o que o código faz hoje; o que ainda não existe aparece como "planejado" (veja a última seção).

Um formato só: Asender Webhook v1. Os quatro emissores do Asender (eventos da conta, CRM, formulários do Páginas e conector de Integrações) enviam os mesmos três cabeçalhos — Asender-Webhook-Id, Asender-Event e Asender-Signature — e um único verificador serve a todos. Implemente o v1 e ignore os cabeçalhos antigos: eles continuam sendo enviados só até 2027-04-20 (veja Formatos antigos).

Visão geral

#EmissorEventos que envia (Asender-Event)Onde se cadastra
1Eventos da conta (e-mail, SMS, WhatsApp, push, Mail)email.queued, email.failed, sms.sent, sms.failed, whatsapp.sent, whatsapp.failed, push.delivered, push.failed, mail.sent, mail.received, e webhook.test no testeAPI pública /v1/webhooks, com chave de API de escopo webhooks:manage (como usar)
2Destinos de lead do CRMcrm.lead.captured (lead novo) e crm.teste (botão Testar)painel de Marketing, tela Integrações → Nova integração
3Destino "webhook" de formulário (Páginas)form.submitted (cada submissão aceita)editor do formulário, em Destinos → Webhook
4Conector "API própria / CRM próprio" (Integrações)connector.delivery (um lead de formulário entregue à sua API)painel de Admin, tela Integrações

Pontos comuns aos quatro:


O formato v1

Cabeçalhos

CabeçalhoValor
Content-Typeapplication/json (o conector 4 também pode enviar application/x-www-form-urlencoded)
Asender-Webhook-Idid da entrega, o mesmo em todas as tentativas da mesma entrega. É opaco: trate como texto, sem interpretar o formato
Asender-Evento tipo do evento (veja a tabela da visão geral)
Asender-Signaturet=<unix>,v1=<hex> — presente só quando há segredo

Sem prefixo X-, de acordo com a RFC 6648.

Como a assinatura é calculada

conteudo_assinado = "<t>" + "." + <corpo cru, byte a byte>
v1                = hex minúsculo( HMAC-SHA256( segredo, conteudo_assinado ) )
Asender-Signature = "t=<t>,v1=<v1>"

Exemplo (segredo whsec_exemplo, corpo {"ok":true}):

Asender-Webhook-Id: whd_8f3a2c1d4e5b6a7988776655aabbccdd
Asender-Event: email.failed
Asender-Signature: t=1792485815,v1=<hex de 64 caracteres>

Regras do receptor

  1. Leia o corpo como bytes crus, antes de parsear o JSON.
  2. Confira a assinatura (tempo constante) e a tolerância. Se falhar, responda 401 sem processar nada.
  3. Deduplique por Asender-Webhook-Id, na mesma transação em que grava o efeito.
  4. Responda 2xx só depois de gravar o evento; o trabalho pesado vem depois.

Verificadores (v1)

Um verificador por linguagem. Cada um recebe o segredo, os cabeçalhos com nomes em minúsculas e o corpo cru, e devolve verdadeiro só se há um t dentro da tolerância e algum v1 confere. Eles foram executados contra assinaturas geradas pelo mesmo algoritmo dos emissores, com corpo adulterado, segredo errado, t velho, t futuro, cabeçalho ausente e vários v1= na rotação.

Node.js

import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCIA_S = 300; // 5 minutos

// h: cabeçalhos com nomes em minúsculas (req.headers do Node já vem assim).
// corpo: Buffer com os bytes CRUS do request. agora: segundos Unix.
export function verificarAssinaturaV1(segredo, h, corpo, agora = Date.now() / 1000) {
  let t = null;
  const v1s = [];
  for (const parte of (h["asender-signature"] ?? "").split(",")) {
    const i = parte.indexOf("=");
    if (i < 0) continue;
    const k = parte.slice(0, i).trim();
    const v = parte.slice(i + 1).trim();
    if (k === "t") t = v;
    else if (k === "v1") v1s.push(v);
  }
  if (t === null || !/^\d+$/.test(t) || v1s.length === 0) return false;
  if (Math.abs(agora - Number(t)) > TOLERANCIA_S) return false;
  const esperado = createHmac("sha256", segredo).update(`${t}.`).update(corpo).digest();
  return v1s.some((v) => {
    const recebido = Buffer.from(v, "hex");
    return recebido.length === esperado.length && timingSafeEqual(esperado, recebido);
  });
}

Uso com o servidor HTTP nativo (o corpo cru é o Buffer acumulado):

import http from "node:http";
import { verificarAssinaturaV1 } from "./verificacao.mjs";

http.createServer(async (req, res) => {
  const pedacos = [];
  for await (const p of req) pedacos.push(p);
  const corpo = Buffer.concat(pedacos); // bytes crus
  if (!verificarAssinaturaV1(process.env.WEBHOOK_SEGREDO, req.headers, corpo)) {
    res.writeHead(401).end();
    return;
  }
  const id = req.headers["asender-webhook-id"]; // chave de deduplicação
  const evento = JSON.parse(corpo.toString("utf8")); // só depois de conferir
  // grave (id, evento) com restrição de unicidade em id e responda rápido
  res.writeHead(204).end();
}).listen(3000);

Python

import hashlib
import hmac
import re
import time

TOLERANCIA_S = 300  # 5 minutos


def verificar_assinatura_v1(segredo: str, h: dict, corpo: bytes, agora=None) -> bool:
    """h: cabeçalhos com nomes em minúsculas. corpo: bytes CRUS, antes de qualquer json.loads."""
    agora = time.time() if agora is None else agora
    t, v1s = None, []
    for parte in h.get("asender-signature", "").split(","):
        k, sep, v = parte.strip().partition("=")
        if not sep:
            continue
        if k == "t":
            t = v
        elif k == "v1":
            v1s.append(v)
    if t is None or not re.fullmatch(r"[0-9]+", t) or not v1s:
        return False
    if abs(agora - int(t)) > TOLERANCIA_S:
        return False
    esperado = hmac.new(segredo.encode(), t.encode() + b"." + corpo, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(esperado.encode(), v.lower().encode()) for v in v1s)

Uso com o servidor da biblioteca padrão:

import os
from http.server import BaseHTTPRequestHandler, HTTPServer

from verificacao import verificar_assinatura_v1


class Receptor(BaseHTTPRequestHandler):
    def do_POST(self):
        corpo = self.rfile.read(int(self.headers.get("Content-Length", 0)))  # bytes crus
        h = {k.lower(): v for k, v in self.headers.items()}
        if not verificar_assinatura_v1(os.environ["WEBHOOK_SEGREDO"], h, corpo):
            self.send_response(401)
            self.end_headers()
            return
        webhook_id = h.get("asender-webhook-id")  # chave de deduplicação
        # json.loads(corpo) só depois de conferir; grave (webhook_id, evento) e responda rápido
        self.send_response(204)
        self.end_headers()


HTTPServer(("", 3000), Receptor).serve_forever()

Go

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"strconv"
	"strings"
	"time"
)

const toleranciaS = 300 // 5 minutos

// VerificarAssinaturaV1 confere o Asender-Signature (t=<unix>,v1=<hex>[,v1=<hex>]).
// h: cabeçalhos com nomes em minúsculas. corpo: os bytes CRUS do request.
func VerificarAssinaturaV1(segredo string, h map[string]string, corpo []byte, agora time.Time) bool {
	var ts string
	var v1s []string
	for _, parte := range strings.Split(h["asender-signature"], ",") {
		k, v, ok := strings.Cut(strings.TrimSpace(parte), "=")
		if !ok {
			continue
		}
		switch k {
		case "t":
			ts = v
		case "v1":
			v1s = append(v1s, v)
		}
	}
	t, err := strconv.ParseInt(ts, 10, 64)
	if err != nil || len(v1s) == 0 {
		return false
	}
	if d := agora.Unix() - t; d > toleranciaS || d < -toleranciaS {
		return false
	}
	m := hmac.New(sha256.New, []byte(segredo))
	m.Write([]byte(ts + "."))
	m.Write(corpo)
	esperado := m.Sum(nil)
	for _, v := range v1s {
		if recebido, err := hex.DecodeString(v); err == nil && hmac.Equal(esperado, recebido) {
			return true
		}
	}
	return false
}

Uso com net/http (lembre-se de passar os cabeçalhos com nomes em minúsculas):

func receptor(w http.ResponseWriter, r *http.Request) {
	corpo, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) // bytes crus
	if err != nil {
		http.Error(w, "corpo inválido", http.StatusBadRequest)
		return
	}
	h := map[string]string{}
	for k := range r.Header {
		h[strings.ToLower(k)] = r.Header.Get(k)
	}
	if !VerificarAssinaturaV1(os.Getenv("WEBHOOK_SEGREDO"), h, corpo, time.Now()) {
		http.Error(w, "assinatura inválida", http.StatusUnauthorized)
		return
	}
	id := h["asender-webhook-id"] // chave de deduplicação
	_ = id                        // grave (id, evento) com unicidade em id; decodifique o JSON só agora
	w.WriteHeader(http.StatusNoContent)
}

PHP

Requer PHP 8.0 ou superior.

<?php
const TOLERANCIA_S = 300; // 5 minutos

// $h: cabeçalhos com nomes em minúsculas. $corpo: os bytes CRUS (file_get_contents('php://input')).
function verificar_assinatura_v1(string $segredo, array $h, string $corpo, ?int $agora = null): bool {
    $agora ??= time();
    $t = null;
    $v1s = [];
    foreach (explode(',', $h['asender-signature'] ?? '') as $parte) {
        [$k, $v] = array_pad(explode('=', trim($parte), 2), 2, null);
        if ($v === null) {
            continue;
        }
        if ($k === 't') {
            $t = $v;
        } elseif ($k === 'v1') {
            $v1s[] = $v;
        }
    }
    if ($t === null || !ctype_digit($t) || $v1s === []) {
        return false;
    }
    if (abs($agora - (int) $t) > TOLERANCIA_S) {
        return false;
    }
    $esperado = hash_hmac('sha256', $t . '.' . $corpo, $segredo);
    foreach ($v1s as $v) {
        if (hash_equals($esperado, strtolower($v))) {
            return true;
        }
    }
    return false;
}

Uso (o corpo cru vem de php://input; o PHP expõe os cabeçalhos em $_SERVER):

$corpo = file_get_contents('php://input');
$h = [
    'asender-signature' => $_SERVER['HTTP_ASENDER_SIGNATURE'] ?? '',
    'asender-webhook-id' => $_SERVER['HTTP_ASENDER_WEBHOOK_ID'] ?? '',
];
if (!verificar_assinatura_v1(getenv('WEBHOOK_SEGREDO'), $h, $corpo)) {
    http_response_code(401);
    exit;
}
$evento = json_decode($corpo, true); // só depois de conferir
// grave ($h['asender-webhook-id'], $evento) com unicidade no id
http_response_code(204);

1. Eventos da conta

Notificam o resultado do processamento de uma mensagem enviada pela sua conta: e-mail, SMS, WhatsApp, push e a caixa do Asender Mail. É o emissor com cadastro pela API pública, tipos de evento e filtro por tipo.

Cadastro

Pela API pública (/v1/webhooks, chave com o escopo webhooks:manage; veja API /v1/webhooks) ou pelo painel em admin.asender.net → Desenvolvedores → Webhooks (admin ou dono da conta). O segredo (whsec_…) é gerado pelo Asender e mostrado uma única vez, na criação. Endpoint sem segredo não recebe entrega.

Tipos de evento

O corpo é o evento como o serviço de origem o publicou, e o tipo vem em event_type (e em Asender-Event). Hoje os serviços publicam:

TipoOrigemCampos além do envelope
email.queuede-mail de marketing aceito pelo relayrelay_status, relay_body
email.failede-mail de marketing recusado de forma definitivaerror_code, detail
sms.sent, sms.failedSMSprovider_message_id, segments / error_code, detail
whatsapp.sent, whatsapp.failedWhatsAppprovider_message_id / error_code, detail
push.delivered, push.failedpushprovider_message_id / error_code, detail
mail.sent, mail.receivedcaixa do Asender Maildata com from, to, subject (enviado) ou from, subject, mailbox_id (recebido)
webhook.testsó quando você aciona POST /v1/webhooks/{id}/testdata com test e endpoint_id

Não existe email.delivered nem email.opened/clicked/bounced neste emissor hoje. Eventos de uso interno (prefixo interno.) nunca são entregues a webhook de cliente. webhook.test não é assinável: ele só chega quando você pede o teste.

Ao cadastrar um endpoint, a lista de eventos (Events, de 1 a 32 itens, sem repetição) aceita padrões: * (tudo), uma família com ponto e asterisco (email.*, sms.*, whatsapp.*, push.*, mail.*) ou o tipo exato (email.failed).

Exemplo de corpo (email.failed)

{
  "detail": "mailbox unavailable",
  "error_code": "relay_5xx_550",
  "event_type": "email.failed",
  "message_id": "msg_01JABCDEF",
  "tenant_id": "acc_01JABCDEF",
  "timestamp": "2026-10-05T14:03:11.123456789Z"
}

Todo evento traz event_type, tenant_id (a sua conta) e timestamp, e quase sempre message_id. Os eventos mail.* trazem o horário sem frações de segundo e põem os detalhes dentro de data. Trate campos desconhecidos como esperados: novos campos podem ser acrescentados sem aviso. Por privacidade, o número de telefone do destinatário de SMS e WhatsApp não vai no evento; use message_id para consultar a mensagem pela API autenticada.

Asender-Webhook-Id

whd_ seguido de 32 caracteres hexadecimais. É derivado do evento e do endpoint, então é único por evento e por endpoint e igual em toda retentativa. Se você rotacionar o segredo do endpoint (apagando e recriando), as entregas que ainda estavam em retentativa passam a levar outro id.

Retentativa

Veja a tabela: 6 tentativas, prazo de 15 s. Se o seu host acumular falhas seguidas (5), as entregas para ele são adiadas por 60 segundos antes de uma nova sonda — o evento não se perde, só espera.


2. Destinos de lead do CRM

Entregam ao seu sistema cada lead novo capturado pelo CRM (por exemplo, vindo de um formulário). São os "webhooks" da tela Integrações do painel de Marketing e da rota /v1/destinos da API do CRM.

Quando dispara

Uma vez para cada contato novo criado no CRM e para cada destino ativo da conta. Uma pessoa que já existe e só tem os dados atualizados não gera nova entrega. Também dispara quando você aciona o teste do destino.

Onde configurar

No painel de Marketing, Integrações → Nova integração: nome, endereço (URL http ou https) e segredo. Pela API (rotas de sessão do CRM, sem API key):

curl -X POST 'https://api.crm.asender.net/v1/destinos' \
  -H 'Authorization: Bearer <token-de-sessão>' \
  -H 'X-Asender-Tenant: <conta>' \
  -H 'Content-Type: application/json' \
  -d '{ "nome": "Meu sistema", "url": "https://hooks.exemplo.test/asender", "segredo": "um-segredo-longo-e-aleatorio", "ativo": true }'

O segredo é opcional e escolhido por você. Ele é guardado cifrado e nunca é devolvido (a API só informa tem_segredo). Sem segredo, a entrega sai sem assinatura e a tela marca o destino como "Sem assinatura" — configure sempre um segredo.

v1 neste emissor

CabeçalhoValor
Asender-Webhook-Ido número da entrega no CRM (inteiro decimal, como texto); igual em todas as tentativas
Asender-Eventcrm.lead.captured ou crm.teste
Asender-Signaturet=<unix>,v1=<hex>, só com segredo

Corpo

{
  "dados": {
    "contato_id": "con_01JABCDEF",
    "criado": true,
    "ocorreu_em": "2026-10-05T14:03:11.123456Z"
  },
  "evento": "crm.lead.captured",
  "tentativa": 1
}
CampoSignificado
eventocrm.lead.captured para lead real; crm.teste para o botão Testar
dados.contato_ido id público do contato no CRM (use-o para consultar o contato)
dados.criadosempre true nas entregas de lead real
dados.ocorreu_emquando o lead foi capturado (RFC 3339, UTC)
tentativanúmero da tentativa (1 na primeira)

O corpo é enxuto de propósito: não carrega e-mail, telefone nem outro dado pessoal do lead — só o identificador. Para obter os dados, consulte o contato no CRM. A ordem das chaves não é garantida; confira a assinatura sobre os bytes crus.

Como testar

Use o botão Testar do destino, ou a rota:

curl -X POST 'https://api.crm.asender.net/v1/destinos/<id>/testar' \
  -H 'Authorization: Bearer <token-de-sessão>' \
  -H 'X-Asender-Tenant: <conta>'

A resposta é 202 com { "enfileiradas": 1, … }: o teste entra na mesma fila das entregas reais, com a mesma assinatura. Ele só vai para o destino que você escolheu e chega com "evento": "crm.teste" e dados contendo teste: true. O resultado (estado, código HTTP, número de tentativas e o último erro) aparece em GET /v1/destinos/<id>/entregas e na própria tela.

Integrações de conversão

A tela Integrações também tem integrações de conversão (hoje, Meta Conversions API). Elas não são webhooks seus: o Asender chama a API da plataforma de anúncios com o token que você informou, e essa chamada não leva Asender-Signature — a plataforma de anúncios autentica pelo token dela.


3. Destino "webhook" de formulário do Pages

Cada formulário do Páginas pode ter destinos de captura (CRM, Marketing, integração e webhook). O destino webhook entrega os campos enviados pelo visitante ao seu servidor.

Quando dispara

A cada submissão aceita do formulário. A submissão e o despacho são gravados juntos e a entrega acontece em segundo plano: um endpoint fora do ar atrasa a notificação, mas não a perde (até o limite de tentativas), e não atrasa a resposta ao visitante.

Onde configurar

No editor do formulário, em Destinos → Webhook: URL e, opcionalmente, o segredo e o mapa de campos (qual campo do formulário vira qual chave do JSON).

v1 neste emissor

CabeçalhoValor
Asender-Webhook-Idid único desta entrega (whk_…, opaco), único por submissão e por destino; igual em todas as tentativas
Asender-Eventform.submitted
Asender-Signaturet=<unix>,v1=<hex>, só com segredo

Corpo

O corpo é um objeto plano com os campos do formulário (valores em texto), usando como chave o alvo do mapa de campos (email, telefone, nome) ou, se o campo não tiver alvo, o próprio nome do campo:

{
  "email": "ana@test.asender.com.br",
  "nome": "Ana Teste",
  "orcamento": "5000"
}

Não há envelope, tipo de evento nem identificador da submissão no corpo — o identificador está em Asender-Webhook-Id e o tipo em Asender-Event. Os campos são dados pessoais do visitante: trate-os conforme a LGPD.

Redirecionamento

Este emissor não segue redirecionamentos: um 3xx é falha de entrega ("redirecionamento não seguido") e é retentado como qualquer outra; cadastre a URL final e responda 2xx direto nela. Destino que aponta para a rede interna ou tem URL inválida é encerrado sem retentar.


4. Conector "API própria / CRM próprio" (Integrações)

O app API própria / CRM próprio da plataforma de Integrações envia o lead de um formulário do Páginas para qualquer API HTTPS que você aponte, com método, autenticação, corpo e critério de sucesso configuráveis. É o mais flexível dos quatro: além do v1 ele leva Idempotency-Key e Traceparent.

Quando dispara

Para cada lead capturado por um formulário do Páginas que tenha este app como destino. Hoje o app só aceita leads de formulários do Páginas.

Onde configurar

No painel de Admin, tela Integrações, instale o app API própria / CRM próprio e vincule-o como destino (tipo "integração") do formulário. Campos:

CampoValores
URLhttps obrigatório, com nome de host com domínio (IP literal não), sem usuário/senha; porta 443, 8443 ou de 1024 a 65535
MétodoPOST (padrão), PUT ou PATCH
Formatojson (padrão) ou form
Corpo (modelo)modelo com {{variáveis}} (veja abaixo)
Cabeçalhos fixosaté 20, não secretos; não pode fixar Authorization, Cookie, Idempotency-Key nem X-Asender-*
Autenticaçãonenhuma, api_key (cabeçalho — padrão X-API-Key — ou query api_key), bearer, basic ou oauth2_client_credentials
Assinar o corpoliga o HMAC-SHA256 (o segredo precisa de pelo menos 16 caracteres; se você deixar vazio, é gerado)
Critério de sucessoopcional: um caminho JSON da resposta que precisa ser igual a um valor

O painel mostra uma prévia da requisição com os segredos mascarados e permite testar a conexão antes de ativar.

Cabeçalhos que o Asender sempre envia

CabeçalhoValor
Content-Typeapplication/json; charset=utf-8 (ou application/x-www-form-urlencoded)
Asender-Webhook-Idid da entrega (ent_…); igual em todas as tentativas
Asender-Eventconnector.delivery
Asender-Signaturet=<unix>,v1=<hex>, só se "Assinar o corpo" estiver ligado
Idempotency-Keyo mesmo id da entrega; repasse-o à sua API se ela aceita chave de idempotência
Traceparentcontexto de rastreio W3C, estável por entrega (o trace) e específico por tentativa (o span)
Authorization / cabeçalho da chaveconforme o modo de autenticação escolhido

Corpo

O corpo é o modelo que você escreveu, renderizado com o lead. As variáveis disponíveis são contato.*, campos["…"], consentimento.*, origem.*, formulario.*, pagina.*, submissao.*, qualidade.*, entrega.*, instalacao.* e agora, com filtros como |e164, |data_iso, |digitos e |cortar:10. Exemplo de modelo e do corpo resultante para o lead de exemplo do catálogo:

{
  "nome": "{{contato.nome}}",
  "email": "{{contato.email}}",
  "telefone": "{{contato.telefone|e164}}",
  "origem": "{{origem.utm_source}}",
  "formulario": "{{formulario.nome}}",
  "recebido_em": "{{submissao.recebida_em}}"
}
{"nome":"Ana Teste","email":"ana@test.asender.com.br","telefone":"+5511999990000","origem":"google","formulario":"Fale conosco","recebido_em":"2026-10-05T14:03:11Z"}

O modelo é validado ao salvar (variável desconhecida ou filtro inválido são recusados) e a saída tem teto de tamanho.

Como o Asender classifica a resposta

Prazo de 10 segundos por tentativa (e de 64 KB para a resposta lida); redirecionamentos não são seguidos (um 3xx encerra a entrega com erro — cadastre a URL final).

RespostaO que acontece
2xx (e critério de sucesso, se houver, atendido)entregue
2xx com o critério de sucesso não atendidoerro definitivo, sem retentar
408, 425, 429, 5xx (exceto 501 e 505) e falha de rede/timeoutretenta; se vier Retry-After (segundos ou data), ele é respeitado até 1 h
401 e 403a entrega pausa e a instalação passa a pedir credencial nova; não gasta tentativa
qualquer outro 4xxerro definitivo, sem retentar

API /v1/webhooks

Gerencia os endpoints do emissor 1 (eventos da conta). Exige uma chave de API com o escopo webhooks:manage (Authorization: Bearer <chave>); app OAuth de terceiros nunca recebe esse escopo. A conta vem da chave, nunca da requisição. Os campos do JSON usam PascalCase. Referência completa: API pública e chaves de API.

RotaO que faz
GET /v1/webhookslista os endpoints da conta (sem o segredo)
POST /v1/webhookscadastra um endpoint; devolve o segredo uma única vez
GET /v1/webhooks/{id}um endpoint (de outra conta ou inexistente: 404 idêntico)
PATCH /v1/webhooks/{id}altera Url, Events e/ou Active (false pausa as entregas); não rotaciona o segredo
DELETE /v1/webhooks/{id}apaga o endpoint (204)
POST /v1/webhooks/{id}/testenvia um evento webhook.test assinado a esse endpoint

Criar

Url precisa ser https em produção, sem credencial nem fragmento e sem destino interno (loopback, faixa privada, link-local/metadados de nuvem, localhost, .local, .internal); o destino é conferido de novo, no endereço resolvido, a cada entrega. Events aceita *, <família>.* ou tipos exatos do catálogo. Esta rota não guarda estado de idempotência: repetir o POST cria outro endpoint (em dúvida, liste antes).

curl -X POST 'https://api.asender.net/v1/webhooks' \
  -H 'Authorization: Bearer <chave-com-webhooks:manage>' \
  -H 'Content-Type: application/json' \
  -d '{ "Url": "https://hooks.exemplo.com.br/asender", "Events": ["email.*", "sms.failed"], "Active": true }'

Resposta 201 (com Cache-Control: no-store):

{
  "Id": "whk_83fcaff4b496c6e6",
  "Url": "https://hooks.exemplo.com.br/asender",
  "Events": ["email.*", "sms.failed"],
  "Active": true,
  "Secret": "whsec_0123456789abcdef…",
  "RequestId": "…"
}

Guarde o Secret agora: ele nunca mais é exibido. Perdeu? Apague o endpoint e crie outro. O segredo é sempre gerado pelo Asender; não é aceito o seu. Guarde-o só no servidor (cofre ou variável de ambiente).

Alterar, listar e apagar

# pausar
curl -X PATCH 'https://api.asender.net/v1/webhooks/whk_83fcaff4b496c6e6' \
  -H 'Authorization: Bearer <chave>' -H 'Content-Type: application/json' \
  -d '{ "Active": false }'

# listar
curl 'https://api.asender.net/v1/webhooks' -H 'Authorization: Bearer <chave>'

# apagar
curl -X DELETE 'https://api.asender.net/v1/webhooks/whk_83fcaff4b496c6e6' \
  -H 'Authorization: Bearer <chave>'

Testar

curl -X POST 'https://api.asender.net/v1/webhooks/whk_83fcaff4b496c6e6/test' \
  -H 'Authorization: Bearer <chave>'

Faz um POST síncrono, sem retentativa, só para o endpoint pedido (o evento webhook.test não vai para os outros endpoints da conta), com os cabeçalhos v1 e os antigos. Responde 200 sempre que o endpoint existe; o resultado da entrega vem no corpo e o corpo da resposta do seu servidor não é devolvido:

{ "WebhookId": "whk_83fcaff4b496c6e6", "EventId": "evt_…", "EventType": "webhook.test",
  "Delivered": true, "StatusCode": 204, "Reason": "", "DurationMs": 87 }

EventId é o mesmo valor que chega em Asender-Webhook-Id. Reason é "" (entregue), non_2xx, transport_error, destination_blocked (destino interno) ou non_production_blocked (fora de produção só vão domínios de teste, como *.test.asender.com.br, .test e .example).


Retentativas e prazos

1. Eventos da conta2. CRM3. Formulário4. Conector
Prazo por tentativa15 s10 s8 s10 s
Tentativas681212
Espera entre tentativas1 min, 5 min, 15 min, 1 h, 6 h30 s dobrando, até 32 min30 s dobrando, até 6 h30 s dobrando, até 6 h (±20%); respeita Retry-After até 1 h
Duração total aproximada7 h 20 min1 h14 h 30 min14 h 30 min
O que retentatudo que não for 2xxtudo que não for 2xxtudo que não for 2xx408, 425, 429, 5xx e rede
Redirecionamentonão segue (3xx é falha)não seguenão segue (3xx é falha, retentada)não segue (erro)
Depois da últimadescartadofalhouencerrado com o erroestado "morta"

Os tempos são aproximados: as entregas são processadas em ciclos, então a tentativa pode atrasar alguns segundos além do previsto. Esgotadas as tentativas, nenhum emissor reenvia sozinho. Destino que aponta para a rede interna ou tem URL inválida falha na hora, sem retentar. Em todos, cada tentativa é assinada de novo com o t da própria tentativa.

Como responder

Idempotência

Deduplique por Asender-Webhook-Id, em qualquer emissor: ele é estável entre as tentativas da mesma entrega. Guarde-o na mesma transação em que grava o efeito, com uma restrição de unicidade, e trate a violação como "já processado" (responda 2xx). O id é opaco: não interprete o formato, e não o compare entre emissores diferentes (os formatos são independentes). A janela de 300 s protege contra replay; o id protege contra duplicata.

Segurança e rede


Formatos antigos (descontinuados)

Sunset: 2027-04-20. Até essa data cada emissor continua enviando os cabeçalhos abaixo, inalterados, além do v1. Depois dela eles saem (com decisão registrada à parte). Migre para o v1 antes; não escreva código novo sobre estes.
EmissorCabeçalhos antigosAssinatura antiga
1. Eventos da contaX-Asender-Event-Type (tipo do evento), X-Asender-Timestamp (UTC, RFC 3339 com nanossegundos, ex. 2026-10-05T14:03:11.123456789Z), X-Asender-Signaturesha256= + hex de HMAC-SHA256(segredo, timestamp + "." + corpo), com o timestamp exatamente como veio no cabeçalho (texto RFC 3339)
2. CRMX-Asender-Timestamp (segundos Unix), X-Asender-Signaturev1= + hex de HMAC-SHA256(segredo, timestamp + "." + corpo); ausente sem segredo
3. FormulárioX-Asender-Webhook-Id (= Asender-Webhook-Id), X-Asender-Timestamp (segundos Unix, sempre enviado), X-Asender-Signature-V2, X-Asender-SignatureV2: v1= + hex de HMAC(segredo, timestamp + "." + corpo). Legado: sha256= + hex de HMAC(segredo, corpo), sem timestamp (não protege contra replay). Ambos ausentes sem segredo
4. ConectorX-Asender-Entrega-Id (= Asender-Webhook-Id), X-Asender-Tentativa (a partir de 1), X-Asender-Assinaturat=<unix>,v1=<hex> com HMAC(segredo, t + "." + corpo), idêntico ao Asender-Signature; só com "Assinar o corpo". Idempotency-Key e Traceparent não são do formato antigo e não são cobertos por este sunset

Observações para quem ainda usa os antigos:


Planejado

Nada desta seção existe hoje. Está descrito para você planejar a integração; datas e formatos podem mudar.
Evento planejadoQuando
conversion.sentuma conversão é enviada a uma plataforma de anúncio
email.deliveredo provedor do destinatário confirma a entrega de um e-mail de marketing
email.openedo destinatário abre um e-mail de marketing
email.clickedo destinatário clica num link de um e-mail de marketing
email.bouncedum e-mail de marketing volta (bounce)
campaign.finisheduma campanha termina o envio

Já existem e não são planejados: o lead novo (crm.lead.captured, emissor 2) e a submissão de formulário (form.submitted, emissor 3).