Axuz Developers

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)
CadastroPOST /teams/{teamId}/integrations/{id}/webhooksPOST /teams/{teamId}/webhooks
Header de assinaturaX-Axuz-Signature: t=<unix>,v1=<hex>X-Axuz-Signature: sha256=<hex>
O que é assinado"<t>." + corposó o corpo
Anti-replaytimestamp assinado + janela de 5 minnenhum
Id de entregaX-Axuz-Delivery-IdX-Axuz-Delivery
Segredogerado pela Axuz (whsec_...), mostrado uma vezescolhido 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.

  1. Crie a integração com POST /teams/{teamId}/integrations.
  2. Crie o webhook com direction: "out", a URL https e os eventos.
  3. Guarde o secret da 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 é localhost ou *.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, .local ou .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.

HeaderConteúdo
X-Axuz-Eventtipo do evento, por exemplo task.created
X-Axuz-Delivery-Idid da entrega (UUID). Constante entre retentativas e reenvios manuais
X-Axuz-Attemptnúmero da tentativa, a partir de 1
X-Axuz-Signaturet=<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:

  1. Leia o corpo cru antes de fazer JSON.parse; reserializar muda os bytes e quebra a assinatura.
  2. Extraia t e todos os v1 do header (pode haver mais de um v1; aceite se qualquer um casar).
  3. Recalcule o HMAC com o segredo e compare em tempo constante.
  4. Rejeite se |agora - t| > 300 segundos (passado ou futuro): isso barra replay de captura antiga.
  5. Deduplique por X-Axuz-Delivery-Id para 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 (inclusive 3xx).
  • 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 para dead_letter. Ela não é mais tentada sozinha; reenfileire com redeliver (abaixo).
  • O sistema novo não lê Retry-After.
  • Fora de produção (AXUZ_ENV diferente de prd/prod/production, inclusive ausente) nada sai: a entrega vira sunk quando o destino é de teste (*.test, *.invalid, *.example, domínios de exemplo RFC 2606 ou test.<domínio>) e blocked para 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):

POST/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 2xx em 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.

POST/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):

Veja também a referência de webhooks e Autenticação.

Atualizado em