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

Webhooks

Webhook é como a Orbita 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 Orbita Pay envia um POST separado para cada ocorrência, sempre na mesma URL.

Modelo antigo (alguns PSPs)Orbita Pay
/webhook/cashin, /webhook/cashout, /webhook/refund (path por tipo)https://sua-loja.com/hooks/liqfy + 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/liqfy

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;
  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)

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:

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 (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 Orbita 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)

  1. Integrações → Webhooks → Novo endpoint
  2. URL HTTPS genérica (ex.: …/hooks/liqfy)
  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.*). 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 que a API REST devolve (serializado pela mesma allowlist do GET /v1/charges/: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://liqfy.com.br/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, MED ou devolução automática (trava de CPF).
payout.createdSaque solicitado.
payout.paidSaque liquidado com sucesso.
payout.failedSaque falhou ou foi rejeitado.
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.

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.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 Orbita Pay 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: lq_live_...
Content-Type: application/json

Corpo

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

Resposta 201 Created

json
{
  "id": "e5f6a7b8-c9d0-4123-9ef0-123456789012",
  "url": "https://loja.exemplo.com.br/hooks/liqfy",
  "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.

#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-Liqfy-Signaturet=<unix-segundos>,v1=<hex> — o payload assinado é "<t>.<corpoBruto>"Eventos canônicos (charge.*, payout.*)
X-Liqfy-Signaturesha256=<hex> — o payload assinado é só o corpo brutoEventos de conta e kyc.*
X-Liqfy-Delivery-IdId opaco, estável em toda retentativa da mesma entregaAmbos
X-Liqfy-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.LIQFY_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/liqfy',
  // captura o corpo bruto — express.json() o destrói
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const assinatura = req.header('X-Liqfy-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-Liqfy-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('LIQFY_WEBHOOK_SECRET');
$bruto   = file_get_contents('php://input');
$sig     = $_SERVER['HTTP_X_LIQFY_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['LIQFY_WEBHOOK_SECRET'].encode()

@app.post('/hooks/liqfy')
def liqfy_hook():
    bruto = request.get_data()  # bytes, intocados
    sig = request.headers.get('X-Liqfy-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-Liqfy-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-Liqfy-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://liqfy.com.br/v1/webhooks/events?since=2026-07-23T00:00:00Z&limit=50" \
  -H "apikey: $LIQFY_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://liqfy.com.br/v1/webhooks/endpoints/<ENDPOINT_ID>/test" \
  -H "apikey: $LIQFY_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-Liqfy-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://liqfy.com.br/v1/webhooks/deliveries?limit=25" \
  -H "apikey: $LIQFY_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://liqfy.com.br/v1/webhooks/deliveries/<DELIVERY_ID>/replay" \
  -H "apikey: $LIQFY_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://liqfy.com.br/v1/webhooks/endpoints/<ENDPOINT_ID>/rotate-secret" \
  -H "apikey: $LIQFY_API_KEY"

O segredo novo é devolvido uma vez.

A rotação é uma troca instantânea no servidor — todo webhook que a Orbita 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:

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://liqfy.com.br/v1/webhooks/endpoints/<ENDPOINT_ID>" \
  -H "apikey: $LIQFY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://loja.exemplo.com.br/hooks/liqfy-v2", "status": "ACTIVE"}'

curl -X DELETE "https://liqfy.com.br/v1/webhooks/endpoints/<ENDPOINT_ID>" \
  -H "apikey: $LIQFY_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://liqfy.com.br/v1/webhooks/stats" \
  -H "apikey: $LIQFY_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.