Webhooks
Webhook é como a Demo Pay 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 Demo Pay envia um POST separado para cada ocorrência, sempre na mesma URL.
| Modelo antigo (alguns PSPs) | Demo Pay |
|---|---|
/webhook/cashin, /webhook/cashout, /webhook/refund (path por tipo) | https://sua-loja.com/hooks/demo + 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/demoEvite 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':
case 'payment.completed': // legado
// liberar pedido
break;
case 'charge.failed':
case 'charge.expired':
// cancelar / expirar
break;
case 'charge.refunded':
case 'payment.refunded': // alias legado — mesmo estorno
// estorno
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 (alias legado payment.refunded) | canônico |
| Saque liquidado / falhou | payout.paid / payout.failed | canônico (não é refund) |
Marque charge.refunded no mesmo endpoint se precisar de estorno — ele dispara em todos os caminhos de estorno (reembolso do lojista pela API, MED do BACEN e a devolução automática por trava de CPF). payment.refunded é o alias legado do mesmo evento e continua entregue a quem assina por ele. Não confunda com payout.* (saque da carteira).
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": "pix",
"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 (o MED do BACEN passa pelo mesmo caminho) 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). A entrega legada payment.refunded traz os mesmos dados de forma plana em data (refundAmount, currency, origin, reason, endToEndId).
#Várias contas / lojas na mesma URL
Pode. Cada conta Demo Pay 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/demo) - 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.*). 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": { "end_to_end_id": "E00000000202607231431abcdef1234" }
}
}
}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é o mesmo formato público decharge/payoutque a API REST devolve (serializado pela mesma allowlist doGET /v1/charges/:id— nome de provedor, custo, segredo ou payload cru de PSP nunca aparecem aqui).- 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 (sem autenticação):
curl https://demo.zentry.cloud/v1/webhooks/event-catalog| Evento | Quando dispara |
|---|---|
charge.created | Cobrança criada (Pix gerado, aguardando pagamento). |
charge.paid | Cobrança paga e confirmada. |
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, MED ou devolução automática (trava de CPF). |
payout.created | Saque solicitado. |
payout.paid | Saque liquidado com sucesso. |
payout.failed | Saque falhou ou foi rejeitado. |
payment.refunded | Alias legado de charge.refunded — ainda entregue a quem assina por ele. |
Assine os canônicos em events ao registrar seu endpoint. Para estorno, inclua charge.refunded.
Disputas (MED) não disparam webhook
charge.*— a cobrança foi paga, um MED não é falha dela. Detecte a disputa pelo painel de Disputas, pelo e-mail, ou relendo o status da cobrança (disputed). 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 Demo Pay abre um caso de verificação manual e, quando um operador o liquida, entregacharge.paidpara a mesma cobrança. Ocharge.paidposterior é o estado final — trate como paga, mesmo tendo recebidocharge.expiredantes. Nunca assuma quecharge.expiredé definitivo.
#2. Registrar seu endpoint
Endpoint POST /v1/webhooks/endpoints
Cabeçalhos
apikey: dm_live_...
Content-Type: application/jsonCorpo
{
"url": "https://loja.exemplo.com.br/hooks/demo",
"events": ["charge.paid", "charge.failed", "charge.expired"]
}Resposta 201 Created
{
"id": "e5f6a7b8-c9d0-4123-9ef0-123456789012",
"url": "https://loja.exemplo.com.br/hooks/demo",
"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.
#Eventos de conta e KYC
kyc.submitted, kyc.approved, kyc.rejected e um par de eventos de ciclo de vida da conta 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.
#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-Demo-Signature | t=<unix-segundos>,v1=<hex> — o payload assinado é "<t>.<corpoBruto>" | Eventos canônicos (charge.*, payout.*) |
X-Demo-Signature | sha256=<hex> — o payload assinado é só o corpo bruto | Eventos de conta e kyc.* |
X-Demo-Delivery-Id | Id opaco, estável em toda retentativa da mesma entrega | Ambos |
X-Demo-Event-Type | Espelha o nome do evento entregue | 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.DEMO_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/demo',
// captura o corpo bruto — express.json() o destrói
express.raw({ type: 'application/json' }),
(req, res) => {
const assinatura = req.header('X-Demo-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-Demo-Delivery-Id') });
},
);Não quer escrever isso à mão? Os SDKs oficiais de Node.js, Python e .NET trazem
verify(corpoBruto, assinatura, segredo)eparse(...), que cuidam dos dois esquemas — e a verificação deles é testada contra os mesmos vetores nas três linguagens, então o comportamento é idêntico.
#PHP
$segredo = getenv('DEMO_WEBHOOK_SECRET');
$bruto = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_DEMO_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['DEMO_WEBHOOK_SECRET'].encode()
@app.post('/hooks/demo')
def demo_hook():
bruto = request.get_data() # bytes, intocados
sig = request.headers.get('X-Demo-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. 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. - 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-Demo-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-Demo-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://demo.zentry.cloud/v1/webhooks/events?since=2026-07-23T00:00:00Z&limit=50" \
-H "apikey: $DEMO_API_KEY"{
"data": [
{ "eventType": "charge.paid", "payload": { "...": "..." }, "status": "DELIVERED", "lastStatusCode": 200, "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://demo.zentry.cloud/v1/webhooks/endpoints/<ENDPOINT_ID>/test" \
-H "apikey: $DEMO_API_KEY"A entrega de teste hoje sempre manda a amostra no envelope antigo (assinatura
sha256=), independentemente dos eventos que o endpoint assina. Ela exercita conectividade e tratamento de assinatura, não o envelope canônico especificamente.
#7. Checklist de produção
- Endpoint em HTTPS com certificado TLS válido.
- Assinatura verificada sobre o corpo bruto, antes do parse do JSON.
- Comparação em tempo constante (
timingSafeEqual/hash_equals/hmac.compare_digest). - Seu verificador suporta os dois esquemas, se você recebe eventos de conta ou KYC.
- O handler responde
2xxem menos de 5 segundos. Trabalho pesado vai para fila. - Idempotência por
X-Demo-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 entregas recentes
curl "https://demo.zentry.cloud/v1/webhooks/deliveries?limit=25" \
-H "apikey: $DEMO_API_KEY"Cada item traz a contagem de tentativas, o último status code, o último corpo de resposta e o horário da próxima retentativa.
#Reenviar uma entrega que falhou ou foi cancelada
curl -X POST "https://demo.zentry.cloud/v1/webhooks/deliveries/<DELIVERY_ID>/replay" \
-H "apikey: $DEMO_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).
#Girar o segredo
curl -X POST "https://demo.zentry.cloud/v1/webhooks/endpoints/<ENDPOINT_ID>/rotate-secret" \
-H "apikey: $DEMO_API_KEY"O segredo novo é devolvido uma vez.
A rotação é uma troca instantânea no servidor — todo webhook que a Demo Pay 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://demo.zentry.cloud/v1/webhooks/endpoints/<ENDPOINT_ID>" \
-H "apikey: $DEMO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://loja.exemplo.com.br/hooks/demo-v2", "status": "ACTIVE"}'
curl -X DELETE "https://demo.zentry.cloud/v1/webhooks/endpoints/<ENDPOINT_ID>" \
-H "apikey: $DEMO_API_KEY"O PATCH atualiza só os campos que você mandar (url, events, status: ACTIVE/INACTIVE) e nunca devolve o segredo. O DELETE para em definitivo todas as entregas futuras naquele endpoint.
#Estatísticas
curl "https://demo.zentry.cloud/v1/webhooks/stats" \
-H "apikey: $DEMO_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: 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.