Webhooks
Webhooks
Receba eventos do Minstrel com assinatura HMAC verificada, gerencie endpoints pela API, rotacione o segredo sem perder evento e reenvie entregas.
Webhooks sao a forma correta de saber o que aconteceu com uma cobranca. Fazer
polling em GET /v1/payments/{id} funciona, mas desperdica requisicao e
atrasa a sua reacao.
Endpoints, entregas e eventos sao separados por ambiente: uma chave de sandbox
so ve e so recebe eventos de sandbox. Leitura exige webhooks:read; cadastro,
edicao, rotacao, teste e reenvio, webhooks:write; a consulta de eventos,
events:read.
Cadastrar um endpoint
Pelo painel, em Webhooks, ou pela API:
curl -X POST http://localhost:3333/v1/webhooks \
-H "Authorization: Bearer $MINSTREL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.sualoja.com.br/webhooks/minstrel",
"description": "Confirma pedidos pagos e avisa recusas",
"enabledEvents": ["payment.captured", "payment.failed", "invoice.*"],
"customHeaders": { "X-Api-Key": "chave-do-seu-servidor" }
}'
A resposta traz o segredo de assinatura em secret (whsec_...). Guarde-o num
gerenciador de segredos: listagem e consulta nunca o devolvem, e mostra-lo de
novo exige uma revelacao auditada.
Cada ambiente aceita ate 20 endpoints, e uma URL so pode ser cadastrada uma
vez por ambiente - duas inscricoes na mesma URL entregariam cada evento em
dobro. Os dois casos respondem 409.
Regras da URL
Quem dispara a requisicao e o servidor do Minstrel, entao a URL passa por regras que impedem usar o webhook para alcancar redes internas:
- https obrigatorio. Usuario e senha na URL sao recusados; para autenticar, use um cabecalho extra.
- Rede interna e recusada no cadastro: IP privado (10/8, 172.16/12,
192.168/16), loopback, link-local (169.254/16), CGNAT (100.64/10),
multicast, IPv6 local, nome sem dominio (
postgres,api) e sufixos de intranet (.local,.internal,.lan,.corpe similares). - E de novo na conexao, pelo IP resolvido. Um dominio publico que passe a resolver para um endereco interno depois do cadastro (DNS rebinding) e barrado na hora da entrega: basta um endereco interno entre os resolvidos para a entrega falhar com "Endereco interno bloqueado".
localhostso em sandbox, numa instalacao local do Minstrel - e aihttp://tambem vale. Num servidor de producao,localhostseria o proprio servidor do Minstrel, nunca a sua maquina, e e recusado mesmo em sandbox: para testar no seu computador, use um tunel (ngrok, Cloudflare Tunnel).
Eventos e curingas
enabledEvents aceita tipos exatos e curingas:
payment.captured- um evento especifico;payment.*- todos os eventos do recurso, inclusive os que forem criados depois;*- todos (util em desenvolvimento, raramente em producao).
Evento desconhecido e recusado com 422: um erro de digitacao
(payment.capture) criaria um endpoint que nunca recebe nada. Tipos ja cobertos
por um curinga da lista sao descartados, e * substitui todo o resto.
GET /v1/webhooks/event-types devolve o catalogo agrupado por recurso, com o
curinga de cada grupo.
Cabecalhos extras
customHeaders vai em toda entrega - use para a chave de API do seu lado, por
exemplo. Ate 10 cabecalhos, valor em texto ASCII de ate 1.024 caracteres.
Cabecalhos que o Minstrel controla (content-type, user-agent, host,
content-length e afins, alem de qualquer minstrel-*) sao recusados: um
cabecalho extra nunca substitui a assinatura. Nas respostas da API, o valor
aparece mascarado em preview, com no maximo os 4 ultimos caracteres.
Gerenciar endpoints
| Endpoint | O que faz |
|---|---|
GET /v1/webhooks | Endpoints do ambiente, com taxa de sucesso, entregas por hora nas ultimas 24 horas e a ultima entrega. |
GET /v1/webhooks/:id | Um endpoint, com os mesmos numeros, o tempo de resposta e a politica de entrega. |
PATCH /v1/webhooks/:id | Altera URL, descricao, eventos, cabecalhos extras ou enabled. |
DELETE /v1/webhooks/:id | Remocao logica: o endpoint para de receber e as entregas na fila sao canceladas. |
GET /v1/webhooks/summary | Endpoints ativos, com falha e desligados; entregas e taxa de sucesso nas ultimas 24 horas contra as 24 anteriores. |
GET /v1/webhooks/event-types | Catalogo de eventos inscreviveis, agrupado por recurso. |
Na edicao, customHeaders e mesclado com o que ja existe: texto cria ou
substitui, null remove, e o que nao for citado continua como esta - o valor
secreto nao precisa voltar. Trocar a URL zera as falhas seguidas, que eram do
endereco antigo.
Desativar ("enabled": false) cancela as entregas na fila; a resposta informa
quantas em canceledDeliveries. Reativar zera as falhas seguidas. Cada
endpoint traz a situacao em status:
status | Significado |
|---|---|
active | Recebendo normalmente. |
failing | Recebendo, mas a tentativa mais recente falhou (consecutiveFailures maior que zero). |
disabled | Desligado por alguem. |
auto_disabled | Desligado pelo Minstrel apos falhas seguidas (veja disabledReason). |
Rotacao e revelacao do segredo
curl -X POST http://localhost:3333/v1/webhooks/whe_01HQ.../rotate-secret \
-H "Authorization: Bearer $MINSTREL_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "gracePeriod": "24h" }'
A rotacao gera um segredo novo, devolvido uma unica vez em secret:
"gracePeriod": "24h"(o padrao, tambem sem corpo): o segredo atual continua assinando por 24 horas junto com o novo. Durante a janela, o cabecalho de assinatura leva doisv1- tempo para trocar a variavel de ambiente do seu servidor sem perder evento.secretRotation.previousSecretExpiresAtdiz quando a janela fecha."gracePeriod": "none": o segredo atual deixa de valer na hora. Use quando ele vazou.
So existe um segredo anterior: rotacionar de novo dentro da janela descarta o mais antigo na hora.
POST /v1/webhooks/:id/reveal-secret mostra o segredo atual. Exige
webhooks:write e fica registrado na trilha de auditoria como
webhook_endpoint.secret_revealed, com quem pediu e de onde.
Evento de teste
curl -X POST http://localhost:3333/v1/webhooks/whe_01HQ.../test \
-H "Authorization: Bearer $MINSTREL_API_KEY"
Cria um evento webhook_endpoint.test e o entrega na hora, assinado como
qualquer outro - e o jeito de testar a sua verificacao de assinatura. A
resposta e a propria entrega, com o status e o corpo devolvidos pelo seu
servidor.
O evento de teste vai so para o endpoint testado, nunca para os demais
inscritos (nem para os inscritos em *), e nao aceita inscricao: incluir
webhook_endpoint.test em enabledEvents responde 422. Se a entrega
falhar, ela segue a mesma politica de retentativa das entregas reais. Endpoint
desativado responde 409: ative-o antes de testar.
Formato do envelope
{
"id": "evt_01HQ8M5X7P2K3N4R5T6V7W8X9Y",
"type": "payment.captured",
"apiVersion": "2026-01-01",
"createdAt": "2026-01-15T14:32:10.123Z",
"environment": "live",
"sequence": 48213,
"data": {
"object": { "object": "payment", "id": "pay_...", "status": "captured" },
"previousAttributes": { "status": "authorized" }
}
}
O campo sequence e monotonico por organizacao. Use-o para ordenar: com
retentativa, entregas chegam fora de ordem, e aplicar um evento antigo depois
de um recente corromperia o seu estado.
Cada entrega e um POST com estes cabecalhos, alem dos seus cabecalhos extras:
| Cabecalho | Conteudo |
|---|---|
Content-Type | application/json |
User-Agent | Minstrel-Webhooks/1.0 |
Minstrel-Signature | Assinatura, descrita abaixo. |
Minstrel-Event-Id | O id do evento. |
Minstrel-Event-Type | O type do evento. |
Minstrel-Delivery-Attempt | Numero da tentativa desta entrega, a partir de 1. |
Minstrel-Version | Versao do contrato do payload (2026-01-01). |
Verificando a assinatura
Minstrel-Signature: t=1736899200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
te o unix timestamp da assinatura;v1e o HMAC-SHA256 de${t}.${corpo_bruto}com o segredo do endpoint.
Use o segredo inteiro como chave do HMAC, com o prefixo whsec_.
Valide contra o corpo bruto
Este e o erro mais comum ao integrar webhooks. Valide antes de qualquer parse de JSON:
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyMinstrelSignature(params: {
rawBody: string; // string ou Buffer exatamente como chegou
header: string;
secret: string;
toleranceSeconds?: number;
}): boolean {
const parts = params.header.split(',').reduce<Record<string, string[]>>((acc, part) => {
const [key, value] = part.trim().split('=', 2);
if (!key || !value) return acc;
(acc[key] ??= []).push(value);
return acc;
}, {});
const timestamp = Number(parts.t?.[0]);
const signatures = parts.v1 ?? [];
if (!Number.isFinite(timestamp) || signatures.length === 0) return false;
// Rejeita assinaturas antigas: impede replay.
const tolerance = params.toleranceSeconds ?? 300;
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > tolerance) return false;
const expected = createHmac('sha256', params.secret)
.update(`${timestamp}.${params.rawBody}`, 'utf8')
.digest('hex');
// Comparacao em tempo constante.
return signatures.some((candidate) => {
const a = Buffer.from(candidate, 'utf8');
const b = Buffer.from(expected, 'utf8');
return a.length === b.length && timingSafeEqual(a, b);
});
}
Reserializar o JSON muda bytes - ordem de chaves, espacos, escapes unicode - e
invalida a assinatura. Em Express, use
express.raw({ type: 'application/json' }); em Fastify, configure
rawBody: true.
Varias assinaturas no mesmo header
Durante a janela de rotacao do segredo o header carrega dois v1: o do segredo
novo e o do anterior. Aceite se qualquer um casar - e assim que voce troca o
segredo sem perder evento.
Retentativa
Respondemos ao seu 2xx como confirmacao. Qualquer outra resposta (inclusive
redirecionamento, que nao e seguido), erro de conexao ou ausencia de resposta
em 10 segundos agenda nova tentativa:
| Tentativa | Quando |
|---|---|
| 1 | Imediata |
| 2 | ~30 segundos |
| 3 | ~2 minutos |
| 4 | ~10 minutos |
| 5 | ~1 hora |
| 6 | ~6 horas |
| 7 | ~24 horas |
Cada intervalo conta a partir da tentativa anterior e tem jitter de mais ou menos 20%. Sem isso, uma queda de cinco minutos no seu endpoint faria milhares de entregas retentarem no mesmo instante, derrubando-o justamente quando ele volta.
Apos 7 tentativas a entrega fica exhausted. Cada tentativa sem 2xx soma uma
falha seguida no endpoint, que passa a aparecer como failing, e qualquer 2xx
zera a contagem. O desligamento automatico exige as duas condicoes: 20 falhas
seguidas e 3 dias sem nenhuma entrega bem-sucedida (contados da ultima
entrega com sucesso ou, se nunca houve, do cadastro). Uma queda de minutos,
mesmo com muito volume, nao desliga o endpoint. Desligado, ele fica
auto_disabled, com o motivo em disabledReason, e as entregas na fila sao
canceladas. Corrija o endpoint, reative-o com
PATCH e reenvie o que faltou.
Entregas
Cada evento gera uma entrega por endpoint inscrito:
status | Significado |
|---|---|
pending | Na fila para envio. |
succeeded | O endpoint respondeu 2xx. |
failed | A ultima tentativa falhou; ha nova tentativa agendada (nextRetryAt). |
exhausted | As 7 tentativas falharam. |
disabled | Cancelada porque o endpoint foi desativado ou removido. |
curl "http://localhost:3333/v1/webhooks/whe_01HQ.../deliveries?status=failed,exhausted&eventType=payment.*" \
-H "Authorization: Bearer $MINSTREL_API_KEY"
A listagem aceita status (lista separada por virgula), eventType (exato ou
curinga como payment.*), createdFrom, createdTo, search (id exato da
entrega whd_... ou do evento evt_...), limit (1 a 100, padrao 25) e
startingAfter.
GET /v1/webhooks/:id/deliveries/:deliveryId mostra a requisicao como ela
saiu, com o corpo exato que foi assinado e os cabecalhos (os extras
mascarados), e a resposta do seu endpoint, truncada em 8 KB (truncated avisa
quando cortou). A assinatura nao e guardada: ela muda a cada tentativa, porque
o timestamp faz parte dela.
Reenvio
curl -X POST http://localhost:3333/v1/webhooks/whe_01HQ.../deliveries/whd_01HQ.../retry \
-H "Authorization: Bearer $MINSTREL_API_KEY"
Zera as tentativas e coloca a entrega na fila; ela sai em poucos segundos. Vale
tambem para entrega que ja teve sucesso - e o caminho normal para reprocessar
um evento do seu lado depois de corrigir uma indisponibilidade. Endpoint
desativado responde 409: reative-o antes.
Consultar eventos
GET /v1/events lista os eventos do ambiente, do mais novo para o mais antigo
pela sequence, no mesmo envelope entregue aos webhooks. Serve para depurar
sem endpoint ("o evento foi gerado? com que dados?") e para reconciliar depois
de uma indisponibilidade:
curl "http://localhost:3333/v1/events?objectId=pay_01HQ...&type=payment.*" \
-H "Authorization: Bearer $MINSTREL_API_KEY"
| Parametro | Efeito |
|---|---|
type | Tipo exato ou curinga (payment.*). |
objectType, objectId | Todos os eventos de um recurso (pay_..., sub_...). |
createdFrom, createdTo | Periodo. |
search | Id exato do evento ou do objeto. |
limit, startingAfter | Pagina (1 a 100, padrao 25) e cursor: o id do ultimo evento da pagina anterior. |
GET /v1/events/:id traz o payload completo. Com o escopo webhooks:read, a
lista inclui o resumo das entregas de cada evento e o detalhe traz cada entrega
com o endpoint e o status - inclusive as de endpoints ja removidos
(endpointDeleted: true). Sem esse escopo, as entregas ficam de fora, porque
revelariam os endpoints cadastrados.
Idempotencia do seu lado
Entrega duplicada e esperada - preferimos entregar duas vezes a perder um
evento. Guarde o event.id e ignore os repetidos:
const alreadyProcessed = await db.processedEvents.findUnique({
where: { id: event.id },
});
if (alreadyProcessed) return reply.code(200).send();
Catalogo de eventos
GET /v1/webhooks/event-types devolve o catalogo completo, agrupado por
recurso, com o curinga de cada grupo e a descricao de cada tipo. Os mais
usados:
| Evento | Quando acontece |
|---|---|
payment.captured | Valor capturado. E o que confirma a venda. |
payment.failed | Recusado. Inclui a classificacao ABECS em data.object.decline. |
payment.refunded | Estornado integralmente. |
payment.authorization_expiring | Pre-autorizacao perto do fim do prazo de captura (Mastercard 7 dias, demais bandeiras 15). Traz o prazo em data.object.authorizationExpiresAt e a acao tomada em data.object.expiryAction: notify (so avisa), capture ou void. |
payment_method.verified | Cartao validado sem cobranca (Zero Auth). O resultado vem em data.object.verification.status: valid, invalid ou unavailable (servico fora do ar ou nao habilitado, o que nao diz nada sobre o cartao). |
invoice.payment_failed | Cobranca de fatura falhou. Traz a decisao de retentativa. |
subscription.canceled_by_issuer | Emissor determinou a suspensao. Nao retente. |
retry.exhausted | Politica de retentativa esgotada sem aprovacao. |
recipient.updated | Dados de um recebedor de split alterados. |
flow.notification | Emitido por um no "Notificar webhook" do fluxo. |
Alguns tipos do catalogo ainda nao sao emitidos nesta versao: a inscricao e
aceita, mas nenhuma entrega acontece por enquanto. Sao eles
payment.attempt.created, payment_method.expiring,
invoice.finalized, invoice.uncollectible, dispute.*,
chargeback.received, payout.*, flow.published,
connector_account.health_changed, antifraud.assessment_completed e
communication.*.
O webhook_endpoint.test fica fora dos grupos de event-types (vem a parte,
em testEventType) e nao aceita inscricao: vai so para o endpoint testado.
Pre-autorizacao perto de vencer
payment.authorization_expiring sai 24 horas antes do prazo de captura de
uma pre-autorizacao (pagamento authorized, sem captura). A acao nesse momento
vem da configuracao Pre-autorizacao perto de vencer da conta do conector
(Plugins, conta, Recursos), e a que foi aplicada chega em
expiryAction:
expiryAction | O que o Minstrel fez |
|---|---|
notify | So avisou (padrao). Capture ou cancele do seu lado, antes do prazo. |
capture | Capturou o valor autorizado. |
void | Cancelou a autorizacao e liberou o limite do portador. |
{
"type": "payment.authorization_expiring",
"data": {
"object": {
"object": "payment",
"id": "pay_01HQ...",
"status": "authorized",
"authorizationExpiresAt": "2026-10-24T14:30:00.000Z",
"expiryAction": "notify"
}
}
}
Com notify, o aviso e o momento de agir: depois do prazo a autorizacao expira
e a captura responde 409 payment_not_capturable
(Transacoes).
Cartao validado
payment_method.verified sai a cada validacao sem cobranca (Zero Auth), seja
pelo no Validacao de cartao ou
por verify ao salvar o cartao:
{
"type": "payment_method.verified",
"data": {
"object": {
"object": "payment_method",
"id": "pm_01HQ...",
"verification": { "status": "valid", "verifiedAt": "2026-10-17T14:02:11.000Z" }
}
}
}
unavailable quer dizer que o servico nao validou (fora do ar, bandeira sem
suporte ou recurso desligado na conta), o que nao diz nada sobre o cartao: nao
trate como cartao ruim. So invalid justifica pedir outro cartao.