Webhooks
Webhook é como a Moonip avisa o seu backend, em tempo real, que algo mudou. Use webhook para liberar pedido — nunca dependa só de ficar consultando. Se uma entrega se perder, use a API de conciliação para recuperar.
#0. Comece por aqui (perguntas mais comuns)
#Uma URL ou uma URL por evento?
Uma URL só. No painel (Integrações → Webhooks → Novo endpoint) você cadastra um endpoint HTTPS e marca vários eventos nos checkboxes. A Moonip envia um POST separado para cada ocorrência, sempre na mesma URL.
| Modelo antigo (alguns PSPs) | Moonip |
|---|---|
/webhook/cashin, /webhook/cashout, /webhook/refund (path por tipo) | https://sua-loja.com/hooks/moonip + lista de eventos no cadastro |
| Um endpoint HTTP por evento | Um endpoint, vários eventos; o campo type (ou event no legado) diferencia |
URL genérica recomendada:
https://seu-dominio.com.br/hooks/moonipEvite paths do tipo /api/charge/created a menos que o seu roteador exija — o path não escolhe o evento; o checkbox no painel (ou o array events na API) escolhe.
#Como fica a estrutura com “tudo junto”?
Não chega um array com todos os eventos de uma vez. Chega um POST por mudança de status. Você faz switch/if no tipo:
// Express — corpo já parseado só DEPOIS de validar a assinatura no raw body
const type = body.type || body.event; // canônico usa type; legado usa event
switch (type) {
case 'charge.paid':
// liberar pedido
break;
case 'charge.failed':
case 'charge.expired':
// cancelar / expirar
break;
case 'charge.refunded':
// estorno
break;
case 'med_case.created':
case 'med_case.updated':
case 'med.resolved':
// disputa Pix (MED) — veja "Eventos de MED" abaixo
break;
default:
// tipo novo ou desconhecido: 200 OK e ignore
}
res.sendStatus(200); // responda 2xx em poucos segundos#Estorno (refund) — qual evento?
| Precisa de | Evento | Família |
|---|---|---|
| Cobrança paga | charge.paid | canônico |
| Cobrança falhou / expirou | charge.failed / charge.expired | canônico |
| Estorno de cobrança | charge.refunded | canônico |
| Saque liquidado / falhou | payout.paid / payout.failed | canônico (não é refund) |
| Disputa Pix (MED) aberta / mudou / resolvida | med_case.created / med_case.updated / med.resolved | canônico / canônico / envelope legado (não é refund) |
Marque charge.refunded no mesmo endpoint se precisar de estorno — ele dispara em todo estorno: os que você (ou a equipe da Moonip, em seu nome) faz e a devolução automática por trava de CPF. Não confunda com payout.* (saque da carteira).
MED não é estorno: quando o banco do pagador abre um MED, nenhum estorno é criado e charge.refunded não dispara — nem quando o MED abre, nem quando é ganho ou perdido. Acompanhe disputas pelos eventos de MED.
O objeto de estorno traz o valor reembolsado e a origem:
{
"id": "evt_…",
"object": "event",
"type": "charge.refunded",
"created_at": "2026-07-23T14:31:00.000Z",
"data": {
"object": {
"id": "ch_01JABCDEF",
"object": "charge",
"amount": 1000,
"currency": "BRL",
"status": "refunded",
"payment_method": null,
"amount_refunded": 1000,
"settlement": { "end_to_end_id": "E0000…" },
"refund": {
"amount": 1000,
"currency": "BRL",
"origin": "MERCHANT",
"reason": "customer request",
"end_to_end_id": "E0000…"
}
}
}
}refund.origin é MERCHANT para estorno iniciado pelo lojista/admin ou AUTOMATIC_PAYER_RESTRICTION para a devolução Pix automática disparada quando o CPF/CNPJ do pagador liquidado não bateu com o pagador esperado da cobrança. amount_refunded é o total já reembolsado (igual a refund.amount num estorno único; maior em estornos parciais). Neste evento, data.object.amount é o valor original da cobrança (amount_refunded é a parte dela já devolvida) — e payment_method é sempre null (o evento de estorno não traz meio de pagamento). Exceção: eventos de estorno emitidos antes de existir o valor original entregam amount: 0; aí leia a cobrança se precisar do total original de um estorno antigo.
#Várias contas / lojas na mesma URL
Pode. Cada conta Moonip tem suas chaves e seus endpoints, mas a URL do seu servidor pode ser a mesma. Segmente no payload (data.object.id = ch_…, metadata que você enviou na criação da cobrança, etc.).
#Pelo painel (sem API)
- Integrações → Webhooks → Novo endpoint
- URL HTTPS genérica (ex.:
…/hooks/moonip) - Marque pelo menos:
charge.created,charge.paid,charge.failed,charge.expired(+charge.refundedse for estorno) - Salve o secret (aparece uma vez)
- Clique em Testar e confira se o seu servidor respondeu 2xx
#1. O envelope canônico de evento
Integrações novas assinam os eventos canônicos (charge.*, payout.*, med_case.*). Toda entrega é um POST HTTPS na sua URL registrada, com este formato:
{
"id": "evt_5f8a3c1e9b2d4a6f8e0c1b3d5f7a9c1e",
"object": "event",
"api_version": "2026-07-23",
"type": "charge.paid",
"created_at": "2026-07-23T14:31:00.000Z",
"data": {
"object": {
"id": "ch_01JABCDEF",
"object": "charge",
"amount": 1000,
"currency": "BRL",
"status": "paid",
"payment_method": "pix",
"settlement": {}
}
}
}id(evt_…) é o id do evento de negócio — estável em toda tentativa de entrega e em todo endpoint que receba o mesmo evento. Deduplique por ele.data.objecté um objetocharge/payout/med_casepúblico e filtrado por allowlist (nome de provedor, custo, segredo ou payload cru de PSP nunca aparecem aqui), mas nem sempre é o formato REST completo:med_case.*é a única paridade real — o mesmo objeto deGET /v1/med/cases/{id};charge.createdé o payload dechargemais completo (pode trazercustomer,pix,metadata,created_at);charge.paid/charge.failed/charge.expiredentregam uma cobrança reduzida — semcustomer,pix,metadatanemcreated_at;payout.*entrega um saque reduzido — semamount,currencynemfee(sóid,object,statuse, na falha,failure_code/failure_message).statusé um depending,processing,paid,failed.
settlementé{}em todocharge.*, excetocharge.refunded, o único evento que pode preenchersettlement.end_to_end_id.- Trate
typedesconhecido com naturalidade (200 OKe ignore), para que adicionar evento novo nunca quebre você.
#Catálogo de eventos
GET /v1/webhooks/event-catalog devolve a lista viva e autoritativa. Ele exige a sua chave
de API, como toda rota em /v1/webhooks (só as rotas de inbound do provedor são abertas):
curl https://api.moonip.io/v1/webhooks/event-catalog \
-H "Authorization: Bearer $MOONIP_API_KEY"| Evento | Quando dispara |
|---|---|
charge.created | Cobrança criada (Pix/cartão gerado, aguardando pagamento). |
charge.paid | Cobrança paga e confirmada (Pix/cartão liquidado). |
charge.failed | Cobrança falhou ou foi cancelada. |
charge.expired | Cobrança expirou sem pagamento. |
charge.refunded | Cobrança reembolsada (total ou parcial) — reembolso do lojista ou devolução automática (trava de CPF). Não dispara para MED (use med_case.* e med.resolved). |
payout.created | Saque solicitado. |
payout.approved | Saque aprovado, aguardando liquidação. |
payout.paid | Saque liquidado com sucesso. |
payout.failed | Saque falhou ou foi rejeitado. |
med_case.created | Caso MED aberto contra uma cobrança sua — traz o valor contestado e o prazo para contestar. |
med_case.updated | Caso MED mudou — contestado, aceito, encerrado, evidências pedidas. |
med.resolved | MED (devolução pedida pelo pagador) resolvida — informa o destino do valor: devolvido ao lojista, encerrado sem devolução ou encerrado como perda. |
O catálogo atualizado está disponível em GET /webhooks/event-catalog. Ele contém os nomes canônicos de cobranças e saques, eventos MED, KYC e eventos do ciclo de vida da conta.
#Por que um saque falhou
O payout.failed traz o motivo como código estável: data.object.failure_code
(minúsculo, ex. destination_unavailable, invalid_pix_key,
provider_rejected) e data.object.failure_message, uma frase neutra para
that code.
Um saque em várias pernas que quita só em parte não dispara webhook. Quando um saque dividido termina parcialmente (status interno
PARTIALLY_COMPLETED), nenhum eventopayout.*é entregue — o status de que você precisa é o que você lê pela API, ou pela API de conciliação.
Disputas (MED) não disparam webhook
charge.*— a cobrança foi paga, e um MED não é falha dela nem estorno: nemcharge.failednemcharge.refundedsão enviados. Assine os eventos de MED; relendo a cobrança você vêstatus: "disputed"enquanto o caso está aberto. Ver Disputas e MED.
Um
charge.paidtardio pode vir depois de umcharge.expired. Se o adquirente confirmar o pagamento só depois de a cobrança já ter expirado (webhook atrasado / API de status defasada), a Moonip confere o pagamento e, confirmado, entregacharge.paidpara a mesma cobrança. Ocharge.paidposterior é o estado final — trate como paga, mesmo tendo recebidocharge.expiredantes. Nunca assuma quecharge.expiredé definitivo.
Uma expiração também chega aos assinantes de
charge.failed. Quando uma cobrança expira, o evento sai nos dois nomes: um endpoint inscrito só emcharge.failedrecebe a expiração como umcharge.failed. Não trate todocharge.failedcomo recusa — leia o payload para diferenciar.
#2. Registrar seu endpoint
Endpoint POST /v1/webhooks/endpoints
Cabeçalhos
Authorization: Bearer ak_live_...
Content-Type: application/jsonO cabeçalho canônico de autenticação é
Authorization: Bearer <chave>. A forma legadaapikey: <chave>continua funcionando como compatibilidade, mas integrações novas devem usarAuthorization: Bearer.
Corpo
{
"url": "https://loja.exemplo.com.br/hooks/moonip",
"events": ["charge.paid", "charge.failed", "charge.expired"]
}Resposta 201 Created
{
"id": "e5f6a7b8-c9d0-4123-9ef0-123456789012",
"url": "https://loja.exemplo.com.br/hooks/moonip",
"events": ["charge.expired", "charge.failed", "charge.paid"],
"secret": "b8f3a9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1",
"status": "ACTIVE"
}Guarde o
secretna hora. Ele aparece só na criação e é necessário para verificar toda entrega. Nós não mostramos de novo.
#Limites e permissões
| Regra | Valor |
|---|---|
| Endpoints ativos por conta | 10 com KYC aprovado, 2 sem |
| Entregas de teste por hora por conta | 30 com KYC aprovado, 10 sem |
| Esquema da URL | https:// fora de SANDBOX/STAGING — senão 400 WEBHOOK_HTTPS_REQUIRED |
| Host da URL | não pode ser o próprio domínio da plataforma — 400 WEBHOOK_OWN_DOMAIN |
| URL duplicada na conta | 409 WEBHOOK_ENDPOINT_DUPLICATE |
| Acima do limite de endpoints | 409 WEBHOOK_ENDPOINT_LIMIT |
| Acima do limite de testes | 429 WEBHOOK_TEST_RATE_LIMIT |
| Escritas (criar/atualizar/apagar/girar secret/testar/reenviar) | exigem sessão de painel OWNER ou ADMIN; a personificação somente-leitura é recusada (403 WEBHOOK_ROLE_REQUIRED / IMPERSONATION_VIEW_ONLY). Uma chave de API escreve como a própria conta. |
| Conta suspensa ou bloqueada | não pode criar, editar nem testar endpoints — 403 TENANT_NOT_ACTIVE |
#Inscrições canônicas
Novas inscrições de cobranças e saques usam apenas os nomes públicos canônicos. Adicionar nomes antigos na criação ou atualização retorna HTTP 400, código LEGACY_WEBHOOK_EVENT e o par event / replacement. Inscrições existentes continuam recebendo eventos; atualizações podem manter os nomes já inscritos. O lojista pode visualizar e remover esses nomes no painel.
payout.approved informa a aprovação antes da liquidação. Seu envelope versionado contém data.object com object: "payout", um identificador po_ e status: "processing". Ele usa a assinatura HMAC com timestamp, como os demais eventos de saque. O identificador do evento é estável por saque, inclusive em republicações.
#Eventos de conta e KYC
kyc.submitted, kyc.approved, kyc.rejected e o par de ciclo de vida da conta tenant.created / tenant.updated também disparam, e usam sempre o envelope mais antigo { event, data } — não são charge.*/payout.*, então não vêm com o invólucro evt_ nem com a assinatura t=,v1=. É por isso que existem dois esquemas de assinatura, logo abaixo.
⚠️ Lacuna de nomenclatura conhecida: os eventos de ciclo de vida da conta emitem hoje um nome de campo interno literalmente no payload — uma violação de glossário rastreada, não parte intencional do contrato público. Veja o gap de nomenclatura conhecido e os followups no guia de migração para os nomes exatos de evento e de campo. Não construa integração permanente contra esse nome de campo.
#Eventos de MED
Três eventos acompanham uma disputa Pix (MED) — o fluxo completo está em Disputas e MED:
| Evento | Envelope / assinatura | Quando dispara |
|---|---|---|
med_case.created | canônico evt_ / t=,v1= | Um caso MED abre contra uma cobrança sua. |
med_case.updated | canônico evt_ / t=,v1= | O caso muda de status (appealed, accepted, rejected, closed) ou a plataforma te pede evidências. |
med.resolved | legado {event,data} / sha256= | O caso chega ao desfecho; diz para onde foi o dinheiro. |
med_case.created / med_case.updated trazem em data.object o mesmo objeto
med_case que GET /v1/med/cases/{id} devolve:
{
"id": "evt_9c2f4e1a7b3d5c8e0f1a2b3c4d5e6f70",
"object": "event",
"api_version": "2026-07-23",
"type": "med_case.created",
"created_at": "2026-09-23T14:02:12.000Z",
"data": {
"object": {
"id": "med_3f1c2b9e-0d7a-4a55-9a51-6f0e3c1d2b10",
"object": "med_case",
"type": "med",
"status": "open",
"amount": 15000,
"currency": "BRL",
"charge_id": "ch_6b0f4c1e-2a8d-4c1b-9d0e-1f2a3b4c5d6e",
"charge_metadata": { "order_id": "PED-10442" },
"customer_name": "Maria Silva",
"reason": "Pagador relata golpe",
"response_deadline": "2026-09-30T23:59:59.000Z",
"evidence_requested_at": null,
"appeal_reason": null,
"appealed_at": null,
"evidence_count": 0,
"money_outcome": null,
"opened_at": "2026-09-23T14:02:11.000Z",
"closed_at": null,
"created_at": "2026-09-23T14:02:12.000Z",
"updated_at": "2026-09-23T14:02:12.000Z"
}
}
}O med_case.updated tem o mesmo formato, com type: "med_case.updated" e o caso como
ficou depois da mudança (por exemplo status: "closed" e money_outcome: "refunded_to_merchant").
med.resolved usa o corpo legado {event, data} e a assinatura sha256=:
{
"event": "med.resolved",
"data": {
"infractionId": "3f1c2b9e-0d7a-4a55-9a51-6f0e3c1d2b10",
"transactionId": "6b0f4c1e-2a8d-4c1b-9d0e-1f2a3b4c5d6e",
"amount": 15000,
"currency": "BRL",
"outcome": "closed_without_refund",
"occurredAt": "2026-09-11T14:32:07.000Z"
}
}infractionIdé o id do caso sem o prefixomed_etransactionIdé o id da cobrança sem o prefixoch_:med_+infractionIdé oidque você usa em/v1/med/cases/{id}.outcomeérefunded_to_merchant(o valor retido voltou para o seu saldo),closed_without_refund(encerrado, o valor não voltou) oulost(o valor segue debitado). É o mesmo valor domoney_outcomedo caso.- O mesmo desfecho nunca é avisado duas vezes; um desfecho diferente (uma correção) é.
- Aceitar um caso você mesmo não envia
med.resolved— omed_case.updatedcomstatus: "accepted"avisa.
Os avisos de desfecho (med.resolved, e med_case.updated para accepted / rejected /
closed) podem não chegar: um evento de MED cuja publicação falha não é reenviado (a entrega para o seu endpoint, uma vez publicado o evento,
segue a agenda de retentativa normal). Trate
estes eventos como sinal e reconcilie com GET /v1/med/cases — a API é a fonte da verdade.
#3. Verificar a assinatura
Toda entrega é assinada com HMAC-SHA256 sobre o corpo bruto da requisição, usando o secret do seu endpoint. Há dois esquemas, escolhidos automaticamente pela família do evento.
| Cabeçalho | Esquema | Aplica-se a |
|---|---|---|
X-Moonip-Signature | t=<unix-segundos>,v1=<hex> — o payload assinado é "<t>.<corpoBruto>" | Eventos canônicos (charge.*, payout.*, med_case.*) |
X-Moonip-Signature | sha256=<hex> — o payload assinado é só o corpo bruto | Eventos legados (kyc.*, med.resolved e os eventos de ciclo de vida da conta acima) |
X-Moonip-Delivery-Id | Id opaco, estável em toda retentativa da mesma entrega | Ambos |
X-Moonip-Event-Type | Espelha o nome do evento entregue | Ambos |
X-Moonip-Test | true, só numa entrega de teste; ausente em toda entrega real | Ambos |
O esquema canônico embute um timestamp no material assinado justamente para você recusar replay fora de uma janela de tolerância (recomendado: 5 minutos). O esquema antigo não tem timestamp e não consegue fazer isso.
Verifique sobre o corpo BRUTO. Não faça parse do JSON e re-serialize antes de verificar: qualquer diferença de espaço ou de ordem de chave muda o HMAC e a assinatura falha. É o erro mais comum em integração de webhook, em qualquer linguagem.
#Node.js (Express) — esquema canônico
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SEGREDO = process.env.MOONIP_WEBHOOK_SECRET;
const TOLERANCIA_SEGUNDOS = 300;
function verificaCanonica(corpoBruto, cabecalho, segredo) {
const [parteT, parteV1] = cabecalho.split(',');
const t = Number(parteT?.split('=')[1]);
const v1 = parteV1?.split('=')[1];
if (!Number.isFinite(t) || !v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCIA_SEGUNDOS) return false;
const esperado = crypto
.createHmac('sha256', segredo)
.update(`${t}.${corpoBruto}`)
.digest('hex');
const a = Buffer.from(esperado);
const b = Buffer.from(v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post(
'/hooks/moonip',
// captura o corpo bruto — express.json() o destrói
express.raw({ type: 'application/json' }),
(req, res) => {
const assinatura = req.header('X-Moonip-Signature') || '';
if (!verificaCanonica(req.body.toString('utf8'), assinatura, SEGREDO)) {
return res.status(401).send('assinatura invalida');
}
const { id: eventoId, type, data } = JSON.parse(req.body.toString('utf8'));
// Responda 200 RÁPIDO. O trabalho pesado vai para uma fila.
res.status(200).end();
fila.enfileirar({ eventoId, type, data, deliveryId: req.header('X-Moonip-Delivery-Id') });
},
);Não quer escrever isso à mão? Os SDKs oficiais verificam os dois esquemas (canônico e legado) para você. O ponto de entrada recomendado passa o mapa de cabeçalhos: o SDK escolhe o cabeçalho de assinatura pelo nome (uma opção), ou detecta automaticamente o único cabeçalho cujo nome termina em
-signature— que é exatamente o cabeçalho das entregas da sua marca. Vejapackages/sdk-node/README.md.
// node — @moonip/node
import { MoonipClient } from '@moonip/node';
const client = new MoonipClient({ apiKey: process.env.MOONIP_API_KEY!, baseUrl: 'https://api.moonip.io' });
// forma com mapa de cabeçalhos: detecta o cabeçalho `*-signature` desta marca
if (!client.webhooks.verify(corpoBruto, cabecalhos, segredo)) return res.status(401).end();
// ou fixe o nome do cabeçalho explicitamente (também configurável uma vez como `webhookSignatureHeader`)
client.webhooks.verify(corpoBruto, cabecalhos, segredo, { signatureHeader: 'X-Moonip-Signature' });
client.webhooks.parse(corpoBruto, cabecalhos, segredo);
// superfície de gestão 0.8.0
await client.webhooks.endpoints.test(endpointId); // POST /v1/webhooks/endpoints/:id/test
await client.webhooks.events({ since, limit: 50 }); // GET /v1/webhooks/events (pull)# python — moonip
ok = client.webhooks.verify(corpo_bruto, cabecalhos, segredo) # auto-detecção
ok = client.webhooks.verify(corpo_bruto, cabecalhos, segredo,
signature_header='X-Moonip-Signature') # nome explícito
evento = client.webhooks.parse(corpo_bruto, cabecalhos, segredo)
client.webhooks.endpoints.test(endpoint_id)
client.webhooks.events(since=..., limit=50)// php — moonip/sdk-php
$ok = $client->webhooks->verify($corpoBruto, $cabecalhos, $segredo); // array = mapa de cabeçalhos, auto-detecção
$ok = $client->webhooks->verify($corpoBruto, $cabecalhos, $segredo, 300, null, 'X-Moonip-Signature');
$evento = $client->webhooks->parse($corpoBruto, $cabecalhos, $segredo);
$client->webhooks->endpoints->test($endpointId);
$client->webhooks->events(['since' => $since, 'limit' => 50]);// dotnet — Moonip
bool ok = client.Webhooks.Verify(corpoBruto, cabecalhos, segredo); // dicionário = mapa de cabeçalhos
bool ok2 = client.Webhooks.Verify(corpoBruto, cabecalhos, segredo, 300, "X-Moonip-Signature");
var evt = client.Webhooks.Parse(corpoBruto, cabecalhos, segredo);
await client.Webhooks.Endpoints.TestAsync(endpointId);
await client.Webhooks.EventsAsync(); // ListWebhookEventsQuery opcionalA resolução é idêntica nas quatro: um signatureHeader explícito (correspondência de nome, sem diferenciar maiúsculas) vence; sem ele, o SDK usa o único cabeçalho cujo nome em minúsculas termina em -signature, e falha quando há zero ou dois — ele nunca adivinha. Uma string de assinatura avulsa continua funcionando e não precisa de nome de cabeçalho.
#PHP
$segredo = getenv('MOONIP_WEBHOOK_SECRET');
$bruto = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_MOONIP_SIGNATURE'] ?? '';
$espera = 'sha256=' . hash_hmac('sha256', $bruto, $segredo);
if (!hash_equals($espera, $sig)) {
http_response_code(401);
exit('assinatura invalida');
}
$corpo = json_decode($bruto, true);
http_response_code(200);
// enfileire $corpo para processar#Python (Flask)
import hmac, hashlib, os
from flask import request, abort
SEGREDO = os.environ['MOONIP_WEBHOOK_SECRET'].encode()
@app.post('/hooks/moonip')
def moonip_hook():
bruto = request.get_data() # bytes, intocados
sig = request.headers.get('X-Moonip-Signature', '')
esperado = 'sha256=' + hmac.new(SEGREDO, bruto, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, esperado):
abort(401)
payload = request.get_json()
# responda 200 rápido, processe depois
return '', 200#4. Garantias de entrega, retentativa e DLQ
- Sucesso Qualquer resposta
2xxconfirma a entrega e para as retentativas. - Timeout 30 segundos (10 numa entrega de teste). Resposta mais lenta conta como falha.
- Agenda de retentativa Backoff exponencial (
1s × 2^tentativa), com teto de 6 horas entre tentativas. - Máximo de tentativas 15. Depois da última falha a entrega vai para
CANCELLED(dead-letter) — ela não é apagada, e pode ser reenviada manualmente. - Trocar o seu endpoint As entregas em falha seguem o endpoint que você tem cadastrado agora. Crie um endpoint (ou reative um) e as entregas que estão falhando nos seus outros endpoints migram para ele. Desative ou apague um endpoint, ou pare de assinar um evento, e tudo que ainda estava na fila dele migra para os seus outros endpoints ativos inscritos no mesmo evento — ou é cancelado se não restar nenhum. Trocar a
urlde um endpoint reenfileira as entregas em falha dele na hora, com orçamento de tentativas novo. Uma entrega movida ganha umX-Moonip-Delivery-Idnovo; oiddo evento canônico (evt_…) permanece o mesmo. - Tratamento de 429 Se o seu endpoint devolver
429com cabeçalhoRetry-After(em segundos) ou corpo JSON{ "retry_after": <segundos> }, esse valor é respeitado (limitado a 5 minutos) no lugar do backoff cego. - Estabilidade do id de entrega O
X-Moonip-Delivery-Idé idêntico em todas as tentativas da mesma entrega — use-o como chave primária de deduplicação.
⚠️ Uma entrega pode chegar mais de uma vez. Faça o seu handler idempotente, deduplicando por
X-Moonip-Delivery-Id(ou peloiddo evento canônico,evt_…).
#Padrão de handler idempotente
async function tratar({ deliveryId, eventoId, type, data }) {
// Insert atômico — falha se já vimos esta entrega
const inserido = await db.webhooksProcessados.insertIgnore({
id: deliveryId ?? eventoId,
recebidoEm: new Date(),
});
if (!inserido) return; // já tratado
if (type === 'charge.paid') {
await pedidos.marcarPago(data.object.id, data.object);
}
}#5. API de conciliação (pull)
Se um push se perdeu (seu receptor ficou fora do ar por horas), puxe os eventos perdidos em vez de perdê-los.
curl "https://api.moonip.io/v1/webhooks/events?since=2026-07-23T00:00:00Z&limit=50" \
-H "Authorization: Bearer $MOONIP_API_KEY"{
"data": [
{ "eventType": "charge.paid", "payload": { "...": "..." }, "status": "DELIVERED", "lastStatusCode": 200, "deliveredAt": "2026-07-23T14:31:05.000Z", "createdAt": "2026-07-23T14:31:00.000Z" }
],
"nextCursor": "MjAyNi0wNy0yM1QxNDozMTowMC4wMDBafGRlbF8xMjM="
}- Paginação por cursor em
(createdAt desc, id desc)— devolva onextCursorcomocursorna próxima página, e trate-o como token opaco. - Filtros:
since,until(ISO 8601),status,eventType. - Restrito aos seus próprios endpoints — nunca devolve entrega interna da plataforma.
#6. Testar um endpoint
POST /v1/webhooks/endpoints/:id/test manda uma entrega de amostra síncrona e não persistida, para você conferir status, latência e tratamento de assinatura.
curl -X POST "https://api.moonip.io/v1/webhooks/endpoints/<ENDPOINT_ID>/test" \
-H "Authorization: Bearer $MOONIP_API_KEY"O teste envia uma amostra canônica
charge.paid, com envelopeevt_e assinaturat=,v1=.
#Entregas de teste
Algumas entregas são teste, não evento de negócio:
- o botão Testar, acima;
- o pagamento de uma cobrança de teste (criada com uma chave
ak_test_ou na tela Ambiente de teste do painel); - o saque simulado do Ambiente de teste, que manda o webhook de um saque concluído que nunca existiu.
Elas levam X-Moonip-Test: true — e uma entrega real nunca leva esse cabeçalho. O sample do botão Test é o único que também difere no corpo: ele carimba metadata.test: true no sample canônico (ou data.test: true no legado). O sample legado também traz providerFee e netAmount ilustrativos, que nenhuma entrega de produção emite — não programe contra nenhum dos dois campos. A cobrança de teste paga de verdade e o saque simulado do ambiente de teste entregam corpo idêntico ao do evento de negócio correspondente. Assinatura e os demais cabeçalhos são sempre os de um evento real, então o seu verificador e o seu parser rodam sem mudança — trate o cabeçalho como a fonte da verdade e nunca libere pedido, entregue mercadoria ou credite cliente a partir de uma entrega com ele.
#7. Checklist de produção
- Endpoint em HTTPS com certificado TLS válido.
- Assinatura verificada sobre o corpo bruto, antes do parse do JSON.
- Entregas com
X-Moonip-Test: truenunca liberam pedido. - Comparação em tempo constante (
timingSafeEqual/hash_equals/hmac.compare_digest). - Seu verificador suporta os dois esquemas se algum endpoint continua inscrito num evento legado.
- O handler responde
2xxem menos de 5 segundos. Trabalho pesado vai para fila. - Idempotência por
X-Moonip-Delivery-Id(ouevt_…nos canônicos). -
typedesconhecido é ignorado sem erro (200 OK). - O segredo vem de um cofre de segredos — nunca commitado.
- Existe alerta se nenhum webhook chegar numa janela esperada; use a API de conciliação como rede de segurança.
#8. Operação
#Listar endpoints
curl "https://api.moonip.io/v1/webhooks/endpoints" \
-H "Authorization: Bearer $MOONIP_API_KEY"{
"data": [
{
"id": "e5f6a7b8-c9d0-4123-9ef0-123456789012",
"url": "https://loja.exemplo.com.br/hooks/moonip",
"events": ["charge.paid", "charge.failed"],
"status": "ACTIVE",
"createdAt": "2026-07-23T14:31:00.000Z",
"updatedAt": "2026-07-23T14:31:00.000Z"
}
],
"access": { "canManage": true },
"httpsOnly": true
}Os itens nunca trazem o secret. access.canManage diz se você pode escrever (criar,
atualizar, apagar, girar secret, testar, reenviar) a partir deste chamador; httpsOnly diz
se o ambiente exige https:// nas URLs de endpoint.
#Listar entregas recentes
curl "https://api.moonip.io/v1/webhooks/deliveries?limit=25" \
-H "Authorization: Bearer $MOONIP_API_KEY"Cada item traz a contagem de tentativas, o último status code, o payload entregue, o texto
de lastError e o horário da próxima retentativa. O último corpo de resposta não está
aqui — ele só aparece no detalhe da entrega. Filtros
opcionais: status, eventType, endpointId, limit (padrão 25, máximo 100), offset.
#Inspecionar uma entrega
curl "https://api.moonip.io/v1/webhooks/deliveries/<DELIVERY_ID>" \
-H "Authorization: Bearer $MOONIP_API_KEY"Devolve o registro completo da entrega — incluindo lastResponseBody, paymentMethod e
test — mais o log de tentativas e o endpoint a que ela pertence:
{
"id": "…",
"eventType": "charge.paid",
"status": "FAILED",
"attempts": 3,
"lastStatusCode": 500,
"lastError": "HTTP 500",
"lastResponseBody": "{\"error\":\"…\"}",
"logs": [
{
"id": "…",
"deliveryId": "…",
"attempt": 1,
"statusCode": 500,
"error": "HTTP 500",
"responseBody": "{\"error\":\"…\"}",
"duration": 120,
"url": "https://loja.exemplo.com.br/hooks/moonip",
"createdAt": "2026-07-23T14:31:00.000Z"
}
],
"endpoint": { "url": "https://loja.exemplo.com.br/hooks/moonip", "status": "ACTIVE" }
}logs lista cada tentativa em ordem. Uma entrega que foi movida para outro endpoint traz
cancelReason (ENDPOINT_REPLACED, ENDPOINT_INACTIVE ou ENDPOINT_DELETED) e, quando
foi substituída, supersededById.
#Reenviar uma entrega que falhou ou foi cancelada
curl -X POST "https://api.moonip.io/v1/webhooks/deliveries/<DELIVERY_ID>/replay" \
-H "Authorization: Bearer $MOONIP_API_KEY"Zera o contador de tentativas e reenfileira na hora. Reenvio em massa fica em POST /v1/webhooks/deliveries/replay-bulk, com filtro opcional { status, endpointId, limit } (limit padrão 50, máximo 500; o status padrão é FAILED + CANCELLED).
A semântica de filtros muda entre leitura e reenvio em massa. GET /deliveries e
GET /events ignoram em silêncio um status inválido, uma data inválida ou um
cursor malformado (você recebe uma página sem filtro). O
POST /deliveries/replay-bulk, em vez disso, responde 400 num status desconhecido.
Problema conhecido (documentado como está): o reenvio em massa também aceita
status: DELIVERED ou PROCESSING e reenfileira essas entregas, enquanto o reenvio único
acima recusa os dois com 400.
#Girar o segredo
curl -X POST "https://api.moonip.io/v1/webhooks/endpoints/<ENDPOINT_ID>/rotate-secret" \
-H "Authorization: Bearer $MOONIP_API_KEY"O segredo novo é devolvido uma vez.
A rotação é uma troca instantânea no servidor — todo webhook que a Moonip assinar depois de um rotate-secret bem-sucedido usa o segredo novo. Não existe janela de sobreposição do nosso lado.
Para girar sem perder evento, seu verificador precisa aceitar temporariamente os dois segredos durante o deploy:
// Tenta o novo primeiro, cai no antigo. Remova SEGREDO_ANTIGO depois que o deploy assentar.
const ok = verifica(req, SEGREDO_NOVO) || verifica(req, SEGREDO_ANTIGO);
if (!ok) return res.status(401).end();Ordem das operações:
- Chame rotate-secret → guarde o novo ao lado do antigo.
- Faça deploy do verificador com os dois ativos.
- Deixe assentar por pelo menos um minuto (as retentativas em voo se resolvem).
- Remova o antigo no deploy seguinte.
#Atualizar ou apagar um endpoint
curl -X PATCH "https://api.moonip.io/v1/webhooks/endpoints/<ENDPOINT_ID>" \
-H "Authorization: Bearer $MOONIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://loja.exemplo.com.br/hooks/moonip-v2", "status": "ACTIVE"}'
curl -X DELETE "https://api.moonip.io/v1/webhooks/endpoints/<ENDPOINT_ID>" \
-H "Authorization: Bearer $MOONIP_API_KEY"O PATCH atualiza só os campos que você mandar (url, events, status: ACTIVE/INACTIVE) e nunca devolve o segredo. O DELETE desativa o endpoint em definitivo: ele sai da sua lista e não recebe mais nada, enquanto o histórico de entregas é mantido. As entregas ainda na fila dele migram para os seus outros endpoints ativos inscritos no mesmo evento — ver Garantias de entrega.
#Estatísticas
curl "https://api.moonip.io/v1/webhooks/stats" \
-H "Authorization: Bearer $MOONIP_API_KEY"Devolve a contagem de entregas por estado (PENDING, PROCESSING, DELIVERED, FAILED, CANCELLED) e o total.
#Dúvidas
P: Posso ter vários endpoints? R: Pode. Registre quantos quiser — é útil para separar homologação, produção e um destino de observabilidade.
P: Um endpoint pode misturar eventos canônicos e legados?
R: Pode. events num mesmo endpoint pode incluir as duas famílias (ex.: ["charge.paid", "kyc.approved"]); cada evento entregue usa o envelope/esquema de assinatura do próprio nome.
Inscrições antigas de webhook Nomes antigos de cobranças e saques não são aceitos em novas inscrições. Inscrições existentes continuam recebendo o mesmo formato de mensagem e assinatura. O painel mostra esses nomes com a indicação de remoção e seu substituto; o lojista pode removê-los e selecionar o nome canônico. O nome canônico usa o envelope versionado e a assinatura com timestamp.
P: O que acontece se meu endpoint ficar fora do ar por horas?
R: A entrega continua sendo retentada por até 15 tentativas, com backoff de até 6 horas. Depois disso ela vai para CANCELLED, sem ser apagada — dá para reenviar manualmente ou puxar tudo pela API de conciliação.