Webhook é entrada de dados no seu sistema. Ele não é um canal de escrita na
Tryno: a Platform API v1 é somente leitura, então o padrão é sempre
evento chega → seu sistema reage → (opcional) você enriquece com
GET /v1/….
Veja Receitas de integração.Eventos disponíveis
Os três eventos de membro (
member.joined/member.left/member.removed)
têm um formato GARANTIDO. userId e handle (o handle da TRIBO, não do
membro) estão sempre presentes, em todo caminho de disparo — incluindo
entrada via convite aceito no cadastro. Todo data é montado por um único
construtor no servidor (memberEventPayload), não por código duplicado em
cada rota, então não existe uma origem do evento que omita handle.ping é entregue quando você dispara um teste. Ele não pode ser
assinado na lista events do endpoint — é sempre enviado sob demanda, e seu
data é { "message": "This is a test event from Tryno." }.
O payload é deliberadamente magro: ele carrega identificadores, não o
registro inteiro. Se você precisa do nome do membro, do corpo do post ou do
valor formatado, busque em
/api/v1 com a sua chave depois de receber o
evento. Isso mantém a entrega pequena e impede que dados sensíveis vazem por um
endpoint mal configurado.Registrar um endpoint

Configuração de webhooks na aba API do admin da tribo — URL do endpoint e seleção dos eventos assinados.
string
obrigatório
Precisa ser HTTPS e apontar para um host público.
string[]
obrigatório
Lista de eventos. Valores desconhecidos são descartados em silêncio; se
sobrar zero evento válido, a resposta é
400 no_events. Duplicatas são
removidas.string
Texto livre de até 140 caracteres, só para você identificar o endpoint.
201 traz o endpoint criado, incluindo o segredo de assinatura
(whsec_ + 32 bytes aleatórios em base64url). Diferente das chaves de API, o
segredo permanece visível na listagem — você precisa dele para verificar
assinaturas.
Formato da entrega
POST no seu endpoint, com content-type: application/json e corpo:
string
Id da entrega. É o mesmo valor do cabeçalho
x-tryno-delivery — a chave de
deduplicação.string
Nome do evento, igual ao cabeçalho
x-tryno-event.integer
Unix em segundos do momento em que o evento foi enfileirado — não do envio.
Numa reentrega após 6 horas,
created continua sendo o instante original.object
Os campos do evento (tabela acima). Trate todos como opcionais.
Verificar a assinatura
Este é o único passo que você não pode pular. Um endpoint que aceita qualquerPOST é um endpoint que qualquer pessoa na internet pode usar para injetar
eventos falsos no seu CRM.
A assinatura é HMAC-SHA256 do segredo do endpoint sobre a string
concatenada "<t>" + "." + "<corpo bruto>", em hexadecimal minúsculo.
1
Leia o corpo BRUTO, antes de qualquer parse
Bytes exatamente como chegaram. Ver o aviso abaixo.
2
Quebre o cabeçalho x-tryno-signature
Separe por vírgula, depois por
=, e extraia t e v1. Se faltar
qualquer um dos dois, rejeite.3
Confira a janela de replay
Rejeite se
|agora − t| for maior que 300 segundos. Sem isso, um
atacante que capturou uma entrega antiga pode reenviá-la para sempre.4
Recalcule e compare em tempo constante
HMAC_SHA256(segredo, t + "." + corpoBruto) em hex, comparado com v1
usando uma função de comparação em tempo constante — nunca ==.5
Responda 2xx rápido, processe depois
Qualquer coisa fora de 2xx, ou acima de 10s, conta como falha e vira
reentrega.
Como obter o corpo bruto em cada framework
Como obter o corpo bruto em cada framework
- Express —
express.raw({ type: "application/json" })nessa rota específica (antes de qualquerexpress.json()global), e usereq.bodycomoBuffer. - Fastify — registre um
addContentTypeParser("application/json", { parseAs: "buffer" }, …)que guarde o buffer. - Next.js (App Router) / Hono / Workers —
await request.text(). Chame uma vez e reaproveite a string; o corpo não pode ser lido duas vezes. - Flask —
request.get_data()(orequest.jsonjá consumiu o parse, mas o Flask mantém os bytes). - Django —
request.body. - FastAPI — declare o handler com
request: Requeste useawait request.body(), em vez de um modelo Pydantic no parâmetro. - Laravel —
$request->getContent(). - Go (net/http) —
io.ReadAll(r.Body)e faça o parse depois, a partir desses mesmos bytes.
Por que comparação em tempo constante, se a assinatura é pública?
Por que comparação em tempo constante, se a assinatura é pública?
A assinatura recebida é pública, mas a esperada é derivada do seu segredo.
Um
== comum retorna assim que encontra o primeiro byte diferente, e essa
diferença de tempo é mensurável em rede. Com tentativas suficientes, um
atacante consegue descobrir a assinatura correta byte a byte sem nunca ver o
segredo. timingSafeEqual / hmac.compare_digest / hmac.Equal sempre
percorrem o buffer inteiro.Novas tentativas
Sua resposta é considerada bem-sucedida com qualquer status 2xx. Qualquer outra coisa — incluindo3xx, que não é seguido — ou um tempo de resposta
acima de 10 segundos conta como falha, e a Tryno tenta de novo com espera
crescente:
Depois da sexta tentativa a entrega é marcada como
failed e não é mais tentada.
São, no total, até ~8h40 de janela de recuperação — folga de sobra para um
deploy ou uma indisponibilidade curta do seu lado.
As entregas são drenadas por um cron a cada 5 minutos, em lotes de até
50 por ciclo. Ou seja: a entrega é quase em tempo real, não instantânea,
e a “espera de +1 minuto” na prática arredonda para o próximo ciclo. Se o seu
caso exige latência de segundos, consulte a API em vez de esperar o evento.
Idempotência: obrigatória, não opcional
A v1 não usa trava de linha na fila de entregas. Dois ciclos de cron sobrepostos podem, em tese, entregar a mesma linha duas vezes — e reentregas por timeout também acontecem quando o seu servidor processou o evento mas não conseguiu responder a tempo. Deduplique porx-tryno-delivery (idêntico ao id do corpo). É exatamente
para isso que ele existe: o valor é estável entre todas as tentativas da mesma
entrega.
Guardando o id da entrega antes de processar
Testar e depurar
id, event, status
(pending / success / failed), attempts, responseStatus (o código HTTP
que o seu servidor devolveu, ou null se a conexão nem chegou a completar),
lastError, nextAttemptAt, deliveredAt e createdAt.
Diagnóstico rápido pelo log
Diagnóstico rápido pelo log
responseStatus: 401repetido — sua verificação de assinatura está rejeitando. Quase sempre é corpo re-serializado (veja o aviso do corpo bruto) ou o segredo errado.responseStatus: nullcomlastErrorde timeout — seu handler demorou mais de 10s. Responda2xxprimeiro e processe em fila.responseStatus: 404/405— a URL registrada não é a rota que aceitaPOST.status: pendingsem avançar — o endpoint está desativado (enabled: false). Veja a nota abaixo.responseStatus: 3xx— redirecionamentos não são seguidos. Registre a URL final.
Um endpoint desativado (
enabled: false) não recebe entregas, e as que já
estavam na fila para ele ficam pending até que você o reative — elas não
expiram sozinhas. Se a intenção é desligar de vez, apague o endpoint em vez
de desativá-lo: a exclusão remove também as entregas pendentes.Gerenciar
PATCH é parcial: envie só os campos que mudam. Um corpo sem nenhum campo
reconhecido responde 400 empty. Ao trocar events, o valor é substituído
por inteiro, não somado — envie a lista completa que você quer manter.
Boas práticas de recepção
- Responda em menos de 10s. Valide a assinatura, grave o evento numa fila e
responda
204. Todo o resto é trabalho de segundo plano. - Trate
datacomo extensível. Campos novos podem aparecer numa versão futura; os campos já documentados na tabela acima, porém, são garantidos — não variam por origem do evento (isso vale inclusive paramember.joined). - Não confie na ordem. Um
order.paidpode chegar antes de ummember.joineddo mesmo usuário — o backoff reordena entregas com falha. - Um endpoint por consumidor. Assim você desativa um destino sem afetar os outros, e o log de entregas fica legível.
- Guarde o
whsec_como segredo. Ele é equivalente a uma senha: quem o tem consegue forjar eventos para o seu servidor.
Próximo passo — receitas de integração →
Monte o fluxo completo: evento chega, você enriquece com
/v1 e empurra para
planilha, Zapier ou n8n.
