Minstrel

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, .corp e 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".
  • localhost so em sandbox, numa instalacao local do Minstrel - e ai http:// tambem vale. Num servidor de producao, localhost seria 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

EndpointO que faz
GET /v1/webhooksEndpoints do ambiente, com taxa de sucesso, entregas por hora nas ultimas 24 horas e a ultima entrega.
GET /v1/webhooks/:idUm endpoint, com os mesmos numeros, o tempo de resposta e a politica de entrega.
PATCH /v1/webhooks/:idAltera URL, descricao, eventos, cabecalhos extras ou enabled.
DELETE /v1/webhooks/:idRemocao logica: o endpoint para de receber e as entregas na fila sao canceladas.
GET /v1/webhooks/summaryEndpoints ativos, com falha e desligados; entregas e taxa de sucesso nas ultimas 24 horas contra as 24 anteriores.
GET /v1/webhooks/event-typesCatalogo 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:

statusSignificado
activeRecebendo normalmente.
failingRecebendo, mas a tentativa mais recente falhou (consecutiveFailures maior que zero).
disabledDesligado por alguem.
auto_disabledDesligado 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 dois v1 - tempo para trocar a variavel de ambiente do seu servidor sem perder evento. secretRotation.previousSecretExpiresAt diz 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:

CabecalhoConteudo
Content-Typeapplication/json
User-AgentMinstrel-Webhooks/1.0
Minstrel-SignatureAssinatura, descrita abaixo.
Minstrel-Event-IdO id do evento.
Minstrel-Event-TypeO type do evento.
Minstrel-Delivery-AttemptNumero da tentativa desta entrega, a partir de 1.
Minstrel-VersionVersao do contrato do payload (2026-01-01).

Verificando a assinatura

Minstrel-Signature: t=1736899200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t e o unix timestamp da assinatura;
  • v1 e 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:

TentativaQuando
1Imediata
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:

statusSignificado
pendingNa fila para envio.
succeededO endpoint respondeu 2xx.
failedA ultima tentativa falhou; ha nova tentativa agendada (nextRetryAt).
exhaustedAs 7 tentativas falharam.
disabledCancelada 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"
ParametroEfeito
typeTipo exato ou curinga (payment.*).
objectType, objectIdTodos os eventos de um recurso (pay_..., sub_...).
createdFrom, createdToPeriodo.
searchId exato do evento ou do objeto.
limit, startingAfterPagina (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:

EventoQuando acontece
payment.capturedValor capturado. E o que confirma a venda.
payment.failedRecusado. Inclui a classificacao ABECS em data.object.decline.
payment.refundedEstornado integralmente.
payment.authorization_expiringPre-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.verifiedCartao 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_failedCobranca de fatura falhou. Traz a decisao de retentativa.
subscription.canceled_by_issuerEmissor determinou a suspensao. Nao retente.
retry.exhaustedPolitica de retentativa esgotada sem aprovacao.
recipient.updatedDados de um recebedor de split alterados.
flow.notificationEmitido 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:

expiryActionO que o Minstrel fez
notifySo avisou (padrao). Capture ou cancele do seu lado, antes do prazo.
captureCapturou o valor autorizado.
voidCancelou 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.