Webhooks
Receba eventos da Axuz no seu servidor, verifique a assinatura HMAC, trate retentativas e depure entregas.
Um webhook faz a Axuz enviar um POST JSON para uma URL sua sempre que algo acontece no workspace (uma task é criada, um arquivo é enviado, um post é publicado). Esta página cobre o cadastro, o formato da mensagem, a verificação da assinatura, as retentativas e como depurar.
Existem dois sistemas no código hoje, com assinaturas diferentes:
| Central de Integrações (novo) | Webhooks do workspace (legado) | |
|---|---|---|
| Cadastro | POST /teams/{teamId}/integrations/{id}/webhooks | POST /teams/{teamId}/webhooks |
| Header de assinatura | X-Axuz-Signature: t=<unix>,v1=<hex> | X-Axuz-Signature: sha256=<hex> |
| O que é assinado | "<t>." + corpo | só o corpo |
| Anti-replay | timestamp assinado + janela de 5 min | nenhum |
| Id de entrega | X-Axuz-Delivery-Id | X-Axuz-Delivery |
| Segredo | gerado pela Axuz (whsec_...), mostrado uma vez | escolhido por você |
As seções a seguir descrevem o sistema novo. A seção Webhooks legados do workspace descreve o outro.
Cadastrar um endpoint
Webhooks pertencem a uma integração do time. Crie a integração e depois o webhook de saída. A gestão exige sessão de owner ou admin do time; tokens de integração (axz_bot_*) nunca gerenciam integrações.
- Crie a integração com
POST /teams/{teamId}/integrations. - Crie o webhook com
direction: "out", a URLhttpse os eventos. - Guarde o
secretda resposta: ele aparece uma única vez.
curl
curl -sS -X POST "https://api.axuz.net/teams/$TEAM_ID/integrations/$INTEGRATION_ID/webhooks" \
-H "Authorization: Bearer $AXUZ_TOKEN" \
-H "Content-Type: application/json" \
-d '{"direction":"out","url":"https://hooks.example.test/axuz","events":["task.created","task.moved"]}'
Node
const res = await fetch(`https://api.axuz.net/teams/${teamId}/integrations/${integrationId}/webhooks`, {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.AXUZ_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ direction: 'out', url: 'https://hooks.example.test/axuz', events: ['task.created', 'task.moved'] }),
});
const hook = await res.json(); // hook.secret: guarde agora, não será mostrado de novo
Python
import json, os, urllib.request
req = urllib.request.Request(
f"https://api.axuz.net/teams/{team_id}/integrations/{integration_id}/webhooks",
data=json.dumps({"direction": "out", "url": "https://hooks.example.test/axuz", "events": ["task.created"]}).encode(),
headers={"Authorization": f"Bearer {os.environ['AXUZ_TOKEN']}", "Content-Type": "application/json"},
method="POST",
)
hook = json.load(urllib.request.urlopen(req)) # hook["secret"]: guarde agora
A resposta é 201 com o webhook (id, direction, url, events, active, secret_version) mais o campo secret (whsec_ + 40 caracteres). Listagens e GET nunca devolvem o segredo. Cada integração aceita no máximo 10 webhooks.
Para alterar: PATCH .../webhooks/{webhookId} (campos url, events, active; ao menos um) e DELETE para remover (204).
Tipos de evento
Os eventos assináveis neste sistema são: task.created, task.updated, task.moved, task.deleted, task.shared, task.unshared, task.commented, task.batch, timeline.posted, timeline.post.edited, timeline.post.deleted, thread.updated e file.uploaded. Eventos financeiros não são assináveis e o curinga * não existe aqui. Um evento fora da lista devolve 400 invalid.
URLs recusadas (proteção SSRF)
No cadastro (e no PATCH) a URL é recusada com 400 quando:
- o esquema não é
https; - há credenciais embutidas (
https://usuario:senha@host); - o host é
localhostou*.localhost, um IP literal de loopback, rede privada (10/8,172.16/12,192.168/16), link-local (169.254/16, inclui o endpoint de metadata da cloud) ou não especificado; - o host termina em
.internal,.localou.localdomain, ou émetadata; - há espaço ou caractere de controle na URL.
Isso é só a primeira camada. No envio, o worker resolve o DNS de novo e bloqueia se qualquer IP resolvido for interno (loopback, privado, link-local, CGNAT 100.64/10, faixas reservadas, IPv4 mapeado em IPv6), conecta direto no IP já validado (sem segundo lookup, o que fecha DNS rebinding), ignora proxy de ambiente e nunca segue redirecionamentos: um 3xx conta como falha da entrega. Por isso o seu endpoint precisa responder o 2xx na URL cadastrada, sem redirecionar.
Formato da mensagem
Cada entrega é um POST com Content-Type: application/json e User-Agent: Axuz-Integrations/1.0.
| Header | Conteúdo |
|---|---|
X-Axuz-Event | tipo do evento, por exemplo task.created |
X-Axuz-Delivery-Id | id da entrega (UUID). Constante entre retentativas e reenvios manuais |
X-Axuz-Attempt | número da tentativa, a partir de 1 |
X-Axuz-Signature | t=<unix>,v1=<hex>; o timestamp é renovado a cada tentativa |
O corpo é o envelope do evento:
{
"id": "evt_1",
"type": "task.created",
"team_id": "22222222-2222-2222-2222-222222222222",
"actor_id": "33333333-3333-3333-3333-333333333333",
"created_at": "2030-03-17T17:46:40Z",
"data": { "title": "Revisar proposta" }
}
id identifica o evento (a Axuz garante uma entrega enfileirada por webhook e evento). data é o payload do evento de domínio e varia por tipo; os campos de cada tipo ainda não estão documentados aqui.
Verificar a assinatura
O header é X-Axuz-Signature: t=<unix>,v1=<hex> e
v1 = hex( HMAC-SHA256( secret, "<t>." + corpo_cru ) )
ou seja, o texto do timestamp, um ponto e os bytes exatos do corpo como chegaram. Para verificar:
- Leia o corpo cru antes de fazer
JSON.parse; reserializar muda os bytes e quebra a assinatura. - Extraia
te todos osv1do header (pode haver mais de umv1; aceite se qualquer um casar). - Recalcule o HMAC com o segredo e compare em tempo constante.
- Rejeite se
|agora - t| > 300segundos (passado ou futuro): isso barra replay de captura antiga. - Deduplique por
X-Axuz-Delivery-Idpara barrar replay dentro da janela e reentregas.
Node
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(secret, header, rawBody, now = Math.floor(Date.now() / 1000)) {
const parts = header.split(',').map((p) => p.split('='));
const t = Number(parts.find(([k]) => k === 't')?.[1]);
const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
if (!Number.isInteger(t) || t <= 0 || sigs.length === 0) return false;
const want = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex');
const ok = sigs.some((s) => s.length === want.length && timingSafeEqual(Buffer.from(s.toLowerCase()), Buffer.from(want)));
return ok && Math.abs(now - t) <= 300;
}
Python
import hashlib, hmac, time
def verify(secret: str, header: str, raw_body: bytes, now: int | None = None) -> bool:
now = int(time.time()) if now is None else now
fields = [p.partition("=") for p in header.split(",")]
ts = [v for k, _, v in fields if k.strip() == "t"]
sigs = [v for k, _, v in fields if k.strip() == "v1"]
if len(ts) != 1 or not ts[0].isdigit() or not sigs:
return False
t = int(ts[0])
want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
ok = any(hmac.compare_digest(s.lower(), want) for s in sigs)
return ok and abs(now - t) <= 300
Os arquivos completos e testados estão em examples/webhooks/verify.mjs (inclui um receptor node:http que deduplica por X-Axuz-Delivery-Id) e examples/webhooks/verify.py, com vetores para assinatura válida, corpo adulterado, timestamp velho e header malformado.
Segredo e rotação
O segredo é gerado pela Axuz (whsec_ + 40 caracteres base62, CSPRNG) e guardado cifrado. Para rotacionar, chame POST .../webhooks/{webhookId}/rotate-secret: a resposta traz o novo secret (uma vez) e secret_version sobe. O segredo antigo deixa de valer na hora, então as próximas entregas já saem assinadas com o novo; troque o segredo no seu receptor imediatamente após rotacionar. Durante a troca algumas entregas podem falhar a verificação e serão retentadas pelo backoff.
Respostas, timeout e retentativas
- Sucesso: qualquer
2xx. O corpo da resposta é guardado truncado (1 KiB) só para depuração. - Falha: erro de rede, timeout de 30 s, qualquer não-
2xx(inclusive3xx). - Backoff após a falha de número N: 30 s, 2 min, 10 min, 1 h, 6 h, 24 h e 72 h.
- Desistência: com
max_attempts = 8, a 8ª falha move a entrega paradead_letter. Ela não é mais tentada sozinha; reenfileire com redeliver (abaixo). - O sistema novo não lê
Retry-After. - Fora de produção (
AXUZ_ENVdiferente deprd/prod/production, inclusive ausente) nada sai: a entrega virasunkquando o destino é de teste (*.test,*.invalid,*.example, domínios de exemplo RFC 2606 outest.<domínio>) eblockedpara qualquer outro destino.
Responda 2xx rápido (grave num banco ou fila e processe depois): se o seu processamento passar de 30 s, a Axuz conta como falha e reenvia, e você processa o mesmo evento duas vezes.
Idempotência
A entrega é pelo menos uma vez. O mesmo X-Axuz-Delivery-Id volta em cada retentativa e em cada redeliver. Guarde os ids já processados (com TTL maior que a janela de retentativa, 72 h ou mais) e responda 2xx sem reprocessar quando repetir. O id do evento (id no corpo) também é estável.
Depurar entregas
GET/teams/{teamId}/integrations/{id}/deliveries
Filtros: webhook_id, status, limit (padrão 50, máximo 100) e cursor (paginação por cursor opaco; a resposta traz next_cursor). Estados: pending, delivering, delivered, dead_letter, blocked, sunk e received (entrada). Cada item traz attempts, max_attempts, next_attempt_at, last_status_code, last_response (truncada) e last_error (sem a URL, que pode carregar token). Segredos nunca aparecem.
Para reenviar uma entrega terminal (dead_letter, blocked, sunk ou delivered):
/teams/{teamId}/integrations/{id}/deliveries/{deliveryId}/redeliver
Retorna 202, zera as tentativas e mantém o mesmo X-Axuz-Delivery-Id. Entregas pending ou delivering respondem 409.
Causas típicas: non_2xx_status_500 (seu servidor falhou), invalid_signature do seu lado (corpo reserializado ou segredo antigo), destination_blocked (URL resolve para IP interno), timeout (processamento síncrono demorado).
Boas práticas
- Responda
2xxem poucos segundos e processe de forma assíncrona. - Verifique a assinatura antes de qualquer outra coisa e rejeite com
401. - Compare em tempo constante e valide a janela de 5 minutos (mantenha o relógio do servidor sincronizado com NTP).
- Deduplique por
X-Axuz-Delivery-Id. - Guarde o segredo em variável de ambiente ou cofre, nunca no repositório ou no front-end.
- Rotacione o segredo se ele vazar.
Webhooks legados do workspace
Os endpoints em /teams/{teamId}/webhooks já estão no ar e continuam funcionando. Exigem sessão de owner/admin do time.
/teams/{teamId}/webhooks
Corpo: name, url, secret (opcional, escolhido por você), events (lista do catálogo de GET /webhooks/event-types, * aceito), is_active e format (raw ou slack; slack exige URL https://hooks.slack.com/services/...). Evento fora do catálogo devolve 422 unknown_event_type. A URL passa pelas mesmas regras de SSRF do cadastro acima, mas sem revalidação no envio e sem bloqueio de redirecionamento; use apenas URLs sob seu controle.
Para rotacionar, faça PUT /teams/{teamId}/webhooks/{webhookId} com o novo secret (efeito imediato). A listagem (GET) devolve o secret em claro para owner/admin.
Entrega: POST com Content-Type: application/json, User-Agent: Axuz-Webhook/1.0 e os headers X-Axuz-Event, X-Axuz-Delivery (id da entrega, use para deduplicar), X-Axuz-Attempt e, somente se houver segredo, X-Axuz-Signature: sha256=<hex> onde o hex é HMAC-SHA256(secret, corpo_cru). Não há timestamp: não existe proteção de replay por assinatura, então deduplique por X-Axuz-Delivery. O corpo é:
{
"event": "task.created",
"team_id": "22222222-2222-2222-2222-222222222222",
"data": { "task": {} },
"timestamp": "2030-03-17T17:46:40Z"
}
Com format: "slack" o corpo vira {"text": "..."} e a assinatura é calculada sobre esses bytes.
Retentativas: timeout de 30 s por tentativa; 2xx é sucesso; qualquer outra resposta ou erro agenda nova tentativa com backoff 30 s, 2 min, 10 min, 1 h, 6 h, 24 h, 3 d (e 7 d se houver mais tentativas), e max_attempts = 8 leva a dead_letter. Em 429 e 503 o header Retry-After (segundos ou data HTTP, limitado a 7 dias) substitui o backoff. Entregas presas em delivering por um worker que caiu voltam a pending depois de 5 minutos.
Verificação no formato legado:
Node
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyLegacy(secret, header, rawBody) {
const want = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
return header.length === want.length && timingSafeEqual(Buffer.from(header), Buffer.from(want));
}
Python
import hashlib, hmac
def verify_legacy(secret: str, header: str, raw_body: bytes) -> bool:
want = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(header, want)
Depuração (legado):
- GET
/teams/{teamId}/webhooks/endpoints/{webhookId}/deliveriescom?status=e?limit=(padrão 50, máximo 200). Estados:pending,delivering,delivered,dead_letter. - GET
/teams/{teamId}/webhooks/deliveries/{deliveryId}traz o payload, a assinatura enviada,last_response_code,last_response_bodyelast_error. - POST
/teams/{teamId}/webhooks/deliveries/{deliveryId}/retryreenfileira para agora (se já esgotou, ganha uma tentativa extra). - DELETE
/teams/{teamId}/webhooks/deliveries/{deliveryId}apaga o registro (útil se um payload vazou dado sensível).
Veja também a referência de webhooks e Autenticação.