Webhooks
Webhook é como a AvioraPay 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 AvioraPay envia um POST separado para cada ocorrência, sempre na mesma URL.
| Modelo antigo (alguns PSPs) | AvioraPay |
|---|---|
/webhook/cashin, /webhook/cashout, /webhook/refund (path por tipo) | https://sua-loja.com/hooks/aviorapay + 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/aviorapayEvite 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;
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 (alias legado payment.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 AvioraPay, em seu nome) faz 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).
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": "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 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 AvioraPay 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/aviorapay) - 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": { "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/payout/med_caseque a API REST devolve (serializado pela mesma allowlist doGET /v1/charges/:ide doGET /v1/med/cases/: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://aviorapay.app/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 ou devolução automática (trava de CPF). Não dispara para MED (use med_case.* e med.resolved). |
payout.created | Saque solicitado. |
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. |
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; para disputas Pix, inclua med_case.created, med_case.updated e med.resolved (veja Eventos de MED).
#Um acontecimento, uma entrega — mesmo com dois nomes
Vários eventos têm um nome canônico e um alias legado (charge.paid / payment.completed, payout.created / withdrawal.created, e assim por diante). Eles são o mesmo acontecimento, não dois.
Por endpoint sai uma entrega. Se o endpoint assinar os dois nomes do mesmo par, a entrega sai com o nome canônico — o alias legado não é enviado em separado. Assinar os dois não duplica nada e não é erro, mas também não traz nada: marque só o canônico.
Quem assina apenas o alias legado continua recebendo por ele, com o corpo histórico {event,data} e a assinatura sha256=<hex>, até o sunset anunciado.
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 AvioraPay 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: ak_live_...
Content-Type: application/jsonCorpo
{
"url": "https://loja.exemplo.com.br/hooks/aviorapay",
"events": ["charge.paid", "charge.failed", "charge.expired"]
}Resposta 201 Created
{
"id": "e5f6a7b8-c9d0-4123-9ef0-123456789012",
"url": "https://loja.exemplo.com.br/hooks/aviorapay",
"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.
#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",
"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) vêm ligados por padrão, mas a plataforma pode desligá-los; e 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-Acme-Signature | t=<unix-segundos>,v1=<hex> — o payload assinado é "<t>.<corpoBruto>" | Eventos canônicos (charge.*, payout.*, med_case.*) |
X-Acme-Signature | sha256=<hex> — o payload assinado é só o corpo bruto | Eventos de conta, kyc.* e med.resolved |
X-Acme-Delivery-Id | Id opaco, estável em toda retentativa da mesma entrega | Ambos |
X-Acme-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.AVIORAPAY_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/aviorapay',
// captura o corpo bruto — express.json() o destrói
express.raw({ type: 'application/json' }),
(req, res) => {
const assinatura = req.header('X-Acme-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-Acme-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('AVIORAPAY_WEBHOOK_SECRET');
$bruto = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_ACME_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['AVIORAPAY_WEBHOOK_SECRET'].encode()
@app.post('/hooks/aviorapay')
def aviorapay_hook():
bruto = request.get_data() # bytes, intocados
sig = request.headers.get('X-Acme-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-Acme-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-Acme-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://aviorapay.app/v1/webhooks/events?since=2026-07-23T00:00:00Z&limit=50" \
-H "apikey: $AVIORAPAY_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://aviorapay.app/v1/webhooks/endpoints/<ENDPOINT_ID>/test" \
-H "apikey: $AVIORAPAY_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-Acme-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://aviorapay.app/v1/webhooks/deliveries?limit=25" \
-H "apikey: $AVIORAPAY_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://aviorapay.app/v1/webhooks/deliveries/<DELIVERY_ID>/replay" \
-H "apikey: $AVIORAPAY_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://aviorapay.app/v1/webhooks/endpoints/<ENDPOINT_ID>/rotate-secret" \
-H "apikey: $AVIORAPAY_API_KEY"O segredo novo é devolvido uma vez.
A rotação é uma troca instantânea no servidor — todo webhook que a AvioraPay 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://aviorapay.app/v1/webhooks/endpoints/<ENDPOINT_ID>" \
-H "apikey: $AVIORAPAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://loja.exemplo.com.br/hooks/aviorapay-v2", "status": "ACTIVE"}'
curl -X DELETE "https://aviorapay.app/v1/webhooks/endpoints/<ENDPOINT_ID>" \
-H "apikey: $AVIORAPAY_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://aviorapay.app/v1/webhooks/stats" \
-H "apikey: $AVIORAPAY_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.