Wiki / Core Features / Webhooks de saída

Webhooks de saída

Atualizado por Maxime_48 · há 3 horas · Visualizações: 74

Idiomas: English Français Deutsch Español Português (Brasil) Italiano Nederlands Polski Türkçe Русский 日本語 한국어 中文

Um webhook envia a atividade da sua equipe para uma URL que você controla, no momento em que ela acontece. Toda ação que entra no seu Registro de auditoria pode ser enviada como um POST JSON assinado para o seu próprio servidor, o seu próprio bot ou uma ferramenta interna - sem polling, sem exportação, sem raspar o painel.

Uma regra decide tudo nesta página. Um webhook leva exatamente o que o Registro de atividade da sua equipe mostra, e nada mais. Não é um feed paralelo com escopo próprio, nem uma carga mais rica para máquinas. Se não está nessa página, não está no corpo.

Um webhook de equipe ativo com suas categorias de entrega e o horário da última entrega


📍 Onde encontrar

Configurações da equipe → Webhooks, na página da própria equipe. Até três destinos por equipe, cada um com sua própria URL, suas próprias categorias e seu próprio segredo de assinatura.

Quem pode configurar um: o dono da equipe, ou um membro cujo cargo inclua Remover um servidor da equipe. É a mesma regra que abre o próprio Registro de atividade - um webhook encaminha o que essa página exibe, então poder lê-la é poder encaminhá-la. Veja Cargos e permissões da equipe.


🎯 O que é enviado e o que nunca é

Você escolhe as categorias. Só as que você marcar são entregues:

Categoria O que ela abrange
Recursos Uma funcionalidade ativada, desativada ou com as configurações salvas
Servidores Um servidor adicionado à equipe ou removido dela
Equipe Equipe renomeada, cargos editados, webhooks alterados
Membros Membros convidados, adicionados, removidos ou que saíram
Moderação Sanções, apelos, denúncias, tickets, históricos apagados
Assinatura Mudanças de assinatura da equipe
Faturamento Eventos de faturamento da equipe
Ações da plataforma sobre esta equipe O que um administrador da plataforma fez a um dos seus servidores

🚫 Três coisas que nunca viajam

  • Endereços IP. No Registro de atividade, um IP é mostrado à pessoa a quem pertence e a mais ninguém. Um webhook não tem um leitor para conferir, então o campo não é enviado - ele é removido da carga, não deixado em branco.
  • Ações de backoffice que não são sobre você. A administração de toda a plataforma não carrega nenhuma equipe, então nada na distribuição consegue alcançá-la. A categoria Ações da plataforma sobre esta equipe é somente o que um administrador fez aos seus servidores.
  • Credenciais. Qualquer campo que pareça um token, uma chave ou um segredo chega como ••••••••, nos dois lados de um par antes/depois. Isso acontece de novo na saída, independentemente da máscara que a página aplica.

Os logins não são oferecidos. A trilha de auditoria tem uma categoria auth, e ela está deliberadamente ausente da lista acima: enviar eventos de login a um servidor de terceiros, sem supervisão e por tempo indefinido, é um ato diferente de mostrá-los em uma página que alguém precisou abrir. Isso pode ser adicionado depois.


📦 A entrega

Cada entrega é um único POST com um corpo JSON:

{
  "version": 1,
  "delivery_id": "0a4b2f0e-8c6e-4a5f-9b3d-2f6c1e0d7a11",
  "event": "server.feature.enabled",
  "category": "feature",
  "team": { "id": 12, "name": "Night Shift" },
  "data": {
    "id": 918273,
    "action": "server.feature.enabled",
    "category": "feature",
    "description": "Enabled Starboard",
    "actor_name": "Ava",
    "actor_role": "moderator",
    "is_admin_action": false,
    "team_id": 12,
    "team_name": "Night Shift",
    "server_id": 4,
    "server_name": "Night Shift HQ",
    "subject_label": "Starboard",
    "changes": { "threshold": { "before": 3, "after": 5 } },
    "metadata": null,
    "created_at": "2026-09-06T09:41:12+00:00"
  }
}

Esses quinze campos são todo o data - os mesmos quinze que o Registro de atividade exibe, menos o endereço IP. Qualquer um deles pode ser null: uma ação sem servidor traz "server_id": null, uma ação sem nada para comparar traz "changes": null. Leia-os de forma defensiva em vez de presumir um formato por evento.

  • version é a versão do envelope. Ela existe para o seu receptor poder se ramificar a partir dela, em vez de adivinhar quando o formato crescer.
  • event é a ação auditada. Há mais de cento e trinta delas e a lista cresce com a plataforma - compare por um prefixo ou por category, nunca por uma lista exaustiva de nomes.
  • delivery_id é estável em todas as repetições do mesmo evento. É o que torna possível a deduplicação; veja abaixo.
  • data é a própria entrada de auditoria, o mesmo objeto que o Registro de atividade exibe.

📨 Cabeçalhos

Cabeçalho Valor
User-Agent YAWBDB-Webhooks/1.0
X-YAWBDB-Event O nome da ação, igual ao event do corpo
X-YAWBDB-Delivery O id da entrega, igual ao delivery_id
X-YAWBDB-Timestamp Hora Unix em segundos, de quando a requisição foi montada
X-YAWBDB-Signature v1= seguido do digest em hexadecimal

🔐 Verificar a assinatura

Qualquer pessoa que descubra a URL do seu destino pode enviar um POST com qualquer coisa. A assinatura é como você distingue as nossas entregas das dela.

O digest é um HMAC-SHA256 sobre o timestamp, um ponto e depois o corpo bruto da requisição, com a sua chave de assinatura:

signed_string = X-YAWBDB-Timestamp + "." + raw_body
signature     = "v1=" + hex( hmac_sha256(signed_string, secret) )

Três detalhes são essenciais, e pular qualquer um deles deixa uma brecha:

  • Assine os bytes brutos, antes de qualquer análise de JSON. Reserializar o corpo o altera - ordem das chaves, barras escapadas, unicode - e o digest não vai coincidir.
  • O timestamp está dentro da string assinada, e não apenas ao lado dela. É isso que permite rejeitar repetições: recuse uma entrega cujo timestamp tenha mais de alguns minutos (cinco minutos é uma janela sensata) e um atacante não consegue reenviar uma requisição que capturou na semana passada. Como o timestamp é assinado, ele também não consegue editá-lo.
  • Compare em tempo constante. Um == simples retorna no primeiro byte diferente, e essa diferença de tempo basta para recuperar uma assinatura um byte por vez. Use hash_equals, crypto.timingSafeEqual, hmac.compare_digest - como quer que a sua linguagem chame isso.

PHP

$raw       = file_get_contents('php://input');
$timestamp = (int) ($_SERVER['HTTP_X_YAWBDB_TIMESTAMP'] ?? 0);
$given     = $_SERVER['HTTP_X_YAWBDB_SIGNATURE'] ?? '';

if (abs(time() - $timestamp) > 300) {
    http_response_code(400);   // too old, or clock is wrong
    exit;
}

$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $raw, $secret);

if (!hash_equals($expected, $given)) {
    http_response_code(401);
    exit;
}

http_response_code(200);       // accepted

Node.js (Express)

import crypto from 'node:crypto';

// The raw body is required - express.json() alone destroys it.
app.post('/yawbdb', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = Number(req.get('X-YAWBDB-Timestamp'));
  const given     = req.get('X-YAWBDB-Signature') ?? '';

  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return res.sendStatus(400);

  const expected = 'v1=' + crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${req.body}`)
    .digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(given);

  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);

  res.sendStatus(200);
});

O prefixo v1= nomeia o esquema. Se um dia ele mudar, um receptor que verifica o prefixo pode recusar deliberadamente um desconhecido, em vez de lê-lo errado em silêncio.

🔑 O segredo

64 caracteres hexadecimais, gerados para você, exibidos uma única vez - no painel, logo depois que você cria o destino ou pressiona Novo segredo. Nada o lê de volta depois: nem a página, nem a API, nem uma exportação. Se você o perder, gere um novo.

A rotação vale imediatamente para tudo o que for enviado a partir daquele momento. As entregas já na fila continuam saindo assinadas com o segredo antigo até a fila esvaziar, então espere uma breve sobreposição e atualize primeiro o seu receptor.


🔁 Entrega, repetições e deduplicação

  • Só um 2xx conta como recebido. Qualquer outra coisa - 3xx, 4xx, 5xx - é uma falha. Responda rápido e faça o trabalho depois.
  • Os redirecionamentos não são seguidos. Um 302 é uma falha, não um desvio. Dê-nos a URL final.
  • Os tempos limite são curtos: três segundos para conectar, cinco segundos para responder. Isto é uma notificação, não uma chamada de procedimento remoto - confirme primeiro, processe depois.
  • Quatro tentativas, espaçadas em 30 segundos, 2 minutos e depois 10 minutos: cerca de doze minutos de ponta a ponta. Isso cobre um deploy, não uma queda.
  • As repetições reutilizam o mesmo delivery_id. Um receptor que trata cada POST como novo vai contar em dobro no dia em que a nossa primeira tentativa der certo do seu lado, mas expirar do nosso. Guarde o id e ignore um que você já tratou.
  • A ordem não é garantida. As entregas entram na fila de forma independente; uma repetida chega depois de eventos que vieram depois. Use data.created_at se a ordem importar para você.

⛔ Desligamento automático

Depois de 20 falhas consecutivas, o destino é desligado e o painel avisa isso no cartão. Um destino que parou de responder geralmente parou de vez, e ninguém vem nos avisar - repetir para sempre custaria uma requisição e uma linha de histórico por ação, indefinidamente.

Para reiniciá-lo: conserte o seu receptor, depois use Editar no webhook e marque de novo Entregar eventos neste destino. Ligá-lo de novo é o que zera o contador, então um destino consertado não fica a uma falha de apagar outra vez.


🧪 Enviar um teste

Enviar um teste dispara na hora uma requisição real e assinada contra o seu destino e mostra o código de status e o tempo de ida e volta. Não é uma simulação: passa pelo mesmo código por onde passa toda entrega real, com os mesmos cabeçalhos, então um destino que passa no teste não pode depois recusar o tráfego de produção por causa de um cabeçalho que o teste nunca enviou.

O corpo do teste leva "event": "panel.webhook.test" e "category": "test", para que o seu receptor possa reconhecê-lo.

Espere duas chegadas, não uma. Pressionar o botão é, em si, uma ação de equipe auditada. Então o seu destino recebe a entrega do teste e - se assinar a categoria Equipe - uma segunda entrega, comum, de team.webhook.tested um instante depois. Isso está correto e não é um loop.

Um teste nunca conta para o teto de 20 falhas, e nunca o zera. Depurar um destino não pode desligá-lo.


📜 Tentativas recentes

Tentativas recentes mostra as últimas 20 tentativas desse destino: quando, qual evento, qual número da tentativa, o código de status ou o erro e quanto tempo levou. As falhas trazem um pequeno trecho do que o seu servidor respondeu - que geralmente é a forma mais rápida de descobrir que um proxy reverso, e não o seu código, é quem está dizendo não.

As tentativas são guardadas por 30 dias e removidas toda noite. Isso é resíduo de depuração, não um registro: a própria trilha de auditoria tem a sua, com retenção bem mais longa.


🛡️ Onde nos recusamos a enviar

A URL de um destino precisa apontar para a internet pública. Uma URL que resolve para um endereço de loopback, uma faixa privada, um endereço link-local ou um endereço de metadados de nuvem é recusada - quando você a salva, e de novo antes de cada envio.

A segunda verificação é a que importa com o tempo. Um nome que apontava para algum lugar público no dia em que foi digitado pode apontar para 127.0.0.1 um mês depois, e um webhook dispara enquanto existir. Uma recusa conta como uma falha para o destino e não é repetida: fazer ao resolvedor a mesma pergunta mais três vezes não mudaria nada.

Prefira um destino https. A assinatura prova quem enviou o corpo, mas não o esconde - em http simples, a sua atividade viaja às claras.


💬 Direto em um canal do Discord ou do Slack

Cole uma URL de webhook do Discord (https://discord.com/api/webhooks/…) como destino e o painel envia uma mensagem do Discord em vez do envelope JSON: um embed por entrada, com a descrição como título, com o evento, a categoria, o autor, a equipe, o servidor, o assunto e - quando houver - as alterações como uma pequena comparação. O horário é o da entrada, e o rodapé traz o id da entrega.

O embed é montado a partir do mesmo envelope descrito acima, então herda as suas duas regras: nenhum endereço IP, credenciais mascaradas. As menções são desativadas do lado do Discord, então um canal ou cargo chamado @everyone em uma entrada é postado como texto e não menciona ninguém. Os cabeçalhos X-YAWBDB-* e a assinatura ainda são enviados; o Discord simplesmente os ignora.

O Discord responde 204 No Content a uma mensagem que aceitou, que é o que uma linha verde em Tentativas recentes mostra para ele.

O mesmo vale para uma URL de webhook de entrada do Slack (https://hooks.slack.com/services/…): o painel posta uma mensagem do Slack por entrada - a descrição em negrito, depois uma linha por fato, as alterações em um bloco de código e o horário e o id da entrega embaixo. Ela é montada a partir do mesmo envelope, então valem as mesmas duas regras, e os três caracteres que o Slack reserva para os próprios comandos (<, >, &) são escapados, de modo que um nome digitado por um membro nunca possa virar @channel ou @here. O Slack responde 200 ok a uma mensagem que aceitou. As URLs de workflow e de trigger do Slack não são mensagens: elas mantêm o envelope JSON, que um workflow pode destrinchar com suas próprias variáveis.


💡 O que as pessoas constroem com isso

  • Postar a atividade da sua equipe em um canal do Discord ou do Slack seu - uma URL de webhook do Discord ou do Slack funciona do jeito que está, ou coloque o seu próprio receptor no meio para filtrar e reformatar.
  • Espelhar a trilha no seu próprio armazenamento de logs e guardá-la além da janela de retenção do painel.
  • Avisar alguém quando uma ação de moderação cair fora do expediente.
  • Disparar um build, uma sincronização ou um backup quando uma configuração de funcionalidade mudar.

🔗 Veja também