Webhooks

Como receber e processar notificações em tempo real sobre o status das suas transações.

Webhooks permitem que a Legacy notifique sua aplicação automaticamente sobre mudanças de status em cobranças e saques, sem a necessidade de consultar a API repetidamente.

Sempre que um evento relevante ocorrer — pagamento PIX confirmado, saque concluído, estorno aplicado — a Legacy enviará uma requisição POST para a URL que você informou na criação da transação.


Como configurar

Ao criar um Payin ou Payout, inclua o campo webhookUrl com a URL do seu endpoint:

{
  "paymentMethod": "PIX",
  "amount": 10000,
  "webhookUrl": "https://sua-api.com/webhooks/legacy",
  ...
}

A Legacy enviará todas as atualizações daquela transação específica para essa URL.

📘

Campo opcional, mas altamente recomendado

Sem webhookUrl, você precisará consultar a API manualmente para verificar mudanças de status. O uso de webhooks é a forma mais eficiente e confiável de monitorar suas transações.


Cabeçalhos de cada evento

Toda requisição enviada pela Legacy acompanha os seguintes cabeçalhos:

CabeçalhoDescrição
X-Webhook-IdIdentificador único do evento. Repetido em todas as retentativas do mesmo evento — use-o como chave de idempotência
X-Webhook-EventTipo do evento, ex.: payin.approved, payout.completed
X-Webhook-SignatureAssinatura HMAC do evento. Só é enviado se você ativar a assinatura (veja a seção seguinte)

Assinatura dos eventos

Como sua webhookUrl precisa ser pública, qualquer pessoa que descubra esse endereço pode enviar uma requisição para ela fingindo ser a Legacy — inclusive um falso "status": "APPROVED". Para eliminar esse risco, você pode ativar a assinatura dos eventos: a partir daí cada requisição leva, no cabeçalho X-Webhook-Signature, uma prova criptográfica gerada com um segredo que só você e a Legacy conhecem.

📘

A assinatura é opcional e desligada por padrão

Enquanto você não ativar, nada muda: os eventos continuam chegando exatamente como hoje, sem o cabeçalho de assinatura. Ativar é uma camada extra de segurança, recomendada para qualquer integração que libere pedido, crédito ou acesso automaticamente a partir do webhook.

Como ativar

No Dashboard da Legacy, na mesma área em que ficam suas chaves de API, abra a seção Assinatura de webhooks e clique em Ativar assinatura. Um segredo no formato whsec_..., único da sua conta, será gerado na hora — e a partir desse momento todos os eventos passam a sair assinados.

Ative primeiro e só depois passe a exigir a assinatura no seu endpoint: se o seu código rejeitar eventos sem assinatura antes de você ativar, ele descartará eventos legítimos.

Trate o segredo como uma senha

Quem tem o segredo consegue forjar eventos válidos. Guarde-o em um cofre de segredos e nunca o exponha no navegador, em código-fonte ou em repositórios.

Formato do cabeçalho

X-Webhook-Signature: t=1772000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
CampoSignificado
tMomento em que o evento foi assinado, em segundos desde a época Unix
v1HMAC-SHA256, em hexadecimal, da string {t}.{corpo} usando seu segredo como chave

A string assinada é o timestamp, um ponto, e o corpo bruto da requisição:

1772000000.{"id":"payin_1A2B3C","status":"APPROVED",...}

Como verificar

  1. Leia o corpo bruto da requisição, antes de qualquer parse.
  2. Extraia t e v1 do cabeçalho X-Webhook-Signature.
  3. Calcule o HMAC-SHA256 de {t}.{corpo} usando seu segredo.
  4. Compare com v1 usando uma função de comparação em tempo constante.
  5. Rejeite o evento se t estiver a mais de 5 minutos do horário atual.
🚧

Use o corpo bruto, nunca o JSON re-serializado

Se você fizer JSON.parse e depois JSON.stringify de novo antes de calcular o HMAC, a ordem das chaves e o espaçamento podem mudar e a assinatura não vai bater. Em frameworks que já convertem o corpo automaticamente (Express com express.json(), Laravel, Django), configure o acesso ao corpo original.

Node.js (Express):

const crypto = require('crypto');
const express = require('express');

const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; // whsec_...
const TOLERANCIA_SEGUNDOS = 300;

// `verify` guarda o corpo bruto antes do parse — é ele que foi assinado.
app.use(express.json({
  verify: (req, res, buf) => { req.rawBody = buf.toString('utf8'); },
}));

function assinaturaValida(rawBody, header, secret) {
  if (!header) return false;

  const partes = Object.fromEntries(
    header.split(',').map((p) => {
      const i = p.indexOf('=');
      return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
    })
  );

  const timestamp = Number(partes.t);
  if (!Number.isFinite(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCIA_SEGUNDOS) return false;

  const esperado = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`, 'utf8')
    .digest('hex');

  const a = Buffer.from(partes.v1 || '', 'utf8');
  const b = Buffer.from(esperado, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post('/webhooks/pagamentos', (req, res) => {
  if (!assinaturaValida(req.rawBody, req.get('X-Webhook-Signature'), WEBHOOK_SECRET)) {
    return res.status(401).send('assinatura inválida');
  }

  // Evento autêntico — processe aqui.
  res.sendStatus(200);
});

PHP:

<?php
$secret    = getenv('WEBHOOK_SECRET'); // whsec_...
$rawBody   = file_get_contents('php://input');
$header    = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$tolerancia = 300;

$partes = [];
foreach (explode(',', $header) as $parte) {
    [$chave, $valor] = array_pad(explode('=', $parte, 2), 2, null);
    $partes[trim($chave)] = trim((string) $valor);
}

$timestamp = isset($partes['t']) ? (int) $partes['t'] : 0;

if (abs(time() - $timestamp) > $tolerancia) {
    http_response_code(401);
    exit('evento expirado');
}

$esperado = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

if (!hash_equals($esperado, $partes['v1'] ?? '')) {
    http_response_code(401);
    exit('assinatura inválida');
}

// Evento autêntico — processe aqui.
http_response_code(200);

Python (Flask):

import hashlib
import hmac
import time

from flask import Flask, request

app = Flask(__name__)
WEBHOOK_SECRET = "whsec_..."
TOLERANCIA_SEGUNDOS = 300


def assinatura_valida(raw_body: bytes, header: str, secret: str) -> bool:
    if not header:
        return False

    partes = dict(
        parte.split("=", 1) for parte in header.split(",") if "=" in parte
    )

    try:
        timestamp = int(partes["t"])
    except (KeyError, ValueError):
        return False

    if abs(time.time() - timestamp) > TOLERANCIA_SEGUNDOS:
        return False

    mensagem = f"{timestamp}.".encode() + raw_body
    esperado = hmac.new(secret.encode(), mensagem, hashlib.sha256).hexdigest()

    return hmac.compare_digest(esperado, partes.get("v1", ""))


@app.post("/webhooks/pagamentos")
def receber_webhook():
    if not assinatura_valida(
        request.get_data(),
        request.headers.get("X-Webhook-Signature", ""),
        WEBHOOK_SECRET,
    ):
        return "assinatura inválida", 401

    # Evento autêntico — processe aqui.
    return "", 200

Rotação do segredo

Você pode gerar um segredo novo a qualquer momento pelo Dashboard. Para não perder eventos durante a troca, o segredo anterior continua sendo aceito por 24 horas: nesse período o cabeçalho carrega duas assinaturas, uma com cada segredo.

X-Webhook-Signature: t=1772000000,v1=<assinatura com o segredo novo>,v1=<assinatura com o anterior>
📘

Aceite o evento se qualquer v1 bater

Percorra todos os campos v1 do cabeçalho e considere o evento válido quando pelo menos um corresponder ao HMAC calculado. Os exemplos acima leem apenas o primeiro v1; se você pretende rotacionar o segredo, itere sobre todos.


Eventos de Payin

Quando o status de um Payin mudar, você receberá um payload como este:

{
  "id": "payin_1A2B3C",
  "externalId": "tx_998877",
  "referenceId": "pedido-001",
  "status": "APPROVED",
  "amount": 10000,
  "paymentMethod": "PIX",
  "createdAt": "2026-03-02T12:00:00.000Z"
}
📘

O evento traz apenas os campos de identificação e status

Use GET https://api.legacyecombrasil.com/payin/{id} quando precisar dos dados completos da transação (cliente, itens, taxas). O campo createdAt é o da criação da cobrança, não o do evento — o momento do evento vem no t do cabeçalho de assinatura.

Status possíveis do Payin

StatusSignificado
PENDINGCobrança gerada, aguardando pagamento do cliente
APPROVEDPagamento confirmado, saldo disponível na conta
REFUSEDNegado pelo emissor do cartão ou pelo antifraude
FAILEDFalha catastrófica, geralmente na adquirente
CANCELEDCancelado antes de ser liquidado
REFUNDEDEstorno parcial ou total aplicado
CHARGEBACKCliente contestou a cobrança junto ao banco emissor
MEDMecanismo Especial de Devolução acionado
EXPIREDJanela de pagamento do PIX ou Boleto expirou

Eventos de Payout

Quando o status de um Payout mudar, você receberá:

{
  "id": "payout_1A2B3C",
  "externalId": "trf_554433",
  "status": "COMPLETED",
  "amount": 250000,
  "referenceId": "comissao-001",
  "created_at": "2026-03-02T12:30:00.000Z"
}

Eventos de falha (FAILED, REFUSED, CANCELED, REFUNDED) incluem também o campo reason com o motivo informado pela instituição.

Status possíveis do Payout

StatusSignificado
AWAITING_APPROVALAguardando aprovação manual no Dashboard
PENDINGAprovado, na fila para processamento
PROCESSINGSendo processado pela adquirente
APPROVEDProcessado pela adquirente, aguardando confirmação final
COMPLETEDTransferência concluída com sucesso
FAILEDFalha no processamento, saldo estornado
CANCELEDCancelado antes do processamento
REFUSEDRejeitado pela instituição bancária
REFUNDEDSaldo devolvido à conta de origem

Política de resposta e reenvio

👍

Responda com status 2xx para confirmar o recebimento

Qualquer status HTTP no range 200–299 é aceito como confirmação. Não é necessário nenhum corpo de resposta específico.

Seu endpoint tem 5 segundos para responder. Se ele retornar erro (status 4xx ou 5xx) ou estourar esse tempo, a Legacy reenvia o evento automaticamente, em intervalos crescentes:

TentativaIntervalo
1ª retentativa1 segundo após a falha
2ª retentativa2 segundos após

Esgotadas as tentativas, o evento é registrado como não entregue. Você pode reenviá-lo manualmente pelo Dashboard, ou consultar o estado atual da transação com GET https://api.legacyecombrasil.com/payin/{id}.

🚧

Projete seu endpoint para ser idempotente

Como um mesmo evento pode ser entregue mais de uma vez, use o cabeçalho X-Webhook-Id para garantir que não processará o mesmo evento duas vezes. Não use o id da transação para isso: ele é o mesmo em todos os eventos daquela transação, então eventos legítimos de status diferentes seriam descartados.

📘

Responda rápido e processe depois

Como a janela é curta, registre o evento e responda 2xx imediatamente, deixando o processamento pesado para uma fila assíncrona. Assim uma lentidão sua não vira uma entrega falha.


Did this page help you?