Pular para o conteúdo
AVIORAPAYdocs
PTEN
Ir para o painel

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 eventoUm endpoint, vários eventos; o campo type (ou event no legado) diferencia

URL genérica recomendada:

text
https://seu-dominio.com.br/hooks/aviorapay

Evite 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:

js
// 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 deEventoFamília
Cobrança pagacharge.paidcanônico
Cobrança falhou / expiroucharge.failed / charge.expiredcanônico
Estorno de cobrançacharge.refunded (alias legado payment.refunded)canônico
Saque liquidado / falhoupayout.paid / payout.failedcanônico (não é refund)
Disputa Pix (MED) aberta / mudou / resolvidamed_case.created / med_case.updated / med.resolvedcanô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:

json
{
  "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)

  1. Integrações → Webhooks → Novo endpoint
  2. URL HTTPS genérica (ex.: …/hooks/aviorapay)
  3. Marque pelo menos: charge.created, charge.paid, charge.failed, charge.expired (+ charge.refunded se for estorno)
  4. Salve o secret (aparece uma vez)
  5. 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:

json
{
  "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 de charge/payout/med_case que a API REST devolve (serializado pela mesma allowlist do GET /v1/charges/:id e do GET /v1/med/cases/:id — nome de provedor, custo, segredo ou payload cru de PSP nunca aparecem aqui).
  • Trate type desconhecido com naturalidade (200 OK e 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):

bash
curl https://aviorapay.app/v1/webhooks/event-catalog
EventoQuando dispara
charge.createdCobrança criada (Pix gerado, aguardando pagamento).
charge.paidCobrança paga e confirmada.
charge.failedCobrança falhou ou foi cancelada.
charge.expiredCobrança expirou sem pagamento.
charge.refundedCobranç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.createdSaque solicitado.
payout.paidSaque liquidado com sucesso.
payout.failedSaque falhou ou foi rejeitado.
med_case.createdCaso MED aberto contra uma cobrança sua — traz o valor contestado e o prazo para contestar.
med_case.updatedCaso MED mudou — contestado, aceito, encerrado, evidências pedidas.
med.resolvedMED (devolução pedida pelo pagador) resolvida — informa o destino do valor: devolvido ao lojista, encerrado sem devolução ou encerrado como perda.
payment.refundedAlias 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: nem charge.failed nem charge.refunded sã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.paid tardio pode vir depois de um charge.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, entrega charge.paid para a mesma cobrança. O charge.paid posterior é o estado final — trate como paga, mesmo tendo recebido charge.expired antes. Nunca assuma que charge.expired é definitivo.

#2. Registrar seu endpoint

Endpoint POST /v1/webhooks/endpoints

Cabeçalhos

text
apikey: ak_live_...
Content-Type: application/json

Corpo

json
{
  "url": "https://loja.exemplo.com.br/hooks/aviorapay",
  "events": ["charge.paid", "charge.failed", "charge.expired"]
}

Resposta 201 Created

json
{
  "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 secret na 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:

EventoEnvelope / assinaturaQuando dispara
med_case.createdcanônico evt_ / t=,v1=Um caso MED abre contra uma cobrança sua.
med_case.updatedcanônico evt_ / t=,v1=O caso muda de status (appealed, accepted, rejected, closed) ou a plataforma te pede evidências.
med.resolvedlegado {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:

json
{
  "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=:

json
{
  "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 prefixo med_ e transactionId é o id da cobrança sem o prefixo ch_: med_ + infractionId é o id que 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) ou lost (o valor segue debitado). É o mesmo valor do money_outcome do caso.
  • O mesmo desfecho nunca é avisado duas vezes; um desfecho diferente (uma correção) é.
  • Aceitar um caso você mesmo não envia med.resolved — o med_case.updated com status: "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çalhoEsquemaAplica-se a
X-Acme-Signaturet=<unix-segundos>,v1=<hex> — o payload assinado é "<t>.<corpoBruto>"Eventos canônicos (charge.*, payout.*, med_case.*)
X-Acme-Signaturesha256=<hex> — o payload assinado é só o corpo brutoEventos de conta, kyc.* e med.resolved
X-Acme-Delivery-IdId opaco, estável em toda retentativa da mesma entregaAmbos
X-Acme-Event-TypeEspelha o nome do evento entregueAmbos

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

js
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) e parse(...), 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

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)

python
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 2xx confirma 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 429 com cabeçalho Retry-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 pelo id do evento canônico, evt_…).

#Padrão de handler idempotente

js
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.

bash
curl "https://aviorapay.app/v1/webhooks/events?since=2026-07-23T00:00:00Z&limit=50" \
  -H "apikey: $AVIORAPAY_API_KEY"
json
{
  "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 o nextCursor como cursor na 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.

bash
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 2xx em menos de 5 segundos. Trabalho pesado vai para fila.
  • Idempotência por X-Acme-Delivery-Id (ou evt_… nos canônicos).
  • type desconhecido é 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

bash
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

bash
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

bash
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:

js
// 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:

  1. Chame rotate-secret → guarde o novo ao lado do antigo.
  2. Faça deploy do verificador com os dois ativos.
  3. Deixe assentar por pelo menos um minuto (as retentativas em voo se resolvem).
  4. Remova o antigo no deploy seguinte.

#Atualizar ou apagar um endpoint

bash
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

bash
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.