Skip to main content

Exemplos Completos

import express from 'express';

interface PixWebhookPayload {
  event: 'CashIn' | 'CashOut' | 'CashInReversal' | 'CashOutReversal';
  status: 'PENDING' | 'CONFIRMED' | 'ERROR';
  transactionType: 'PIX';
  movementType: 'CREDIT' | 'DEBIT';
  transactionId: string;
  externalId: string | null;
  endToEndId: string;
  pixKey: string | null;
  feeAmount: number;
  originalAmount: number;
  finalAmount: number;
  processingDate: string;
  errorCode: string | null;
  errorMessage: string | null;
  counterpart?: Counterpart;
  parentTransaction?: ParentTransaction;
  metadata: Record<string, unknown>;
}

interface Counterpart {
  name: string;
  document: string;
  bank: {
    bankISPB: string | null;
    bankName: string | null;
    bankCode: string | null;
    accountBranch: string | null;
    accountNumber: string | null;
  };
}

interface ParentTransaction {
  transactionId: string;
  externalId: string;
  endToEndId: string;
  processingDate: string;
  wasTotalRefunded: boolean;
  remainingAmountForRefund: number;
  metadata: Record<string, unknown>;
  counterpart: Counterpart;
}

const app = express();
app.use(express.json());

// Middleware de autenticação Basic Auth
function validateBasicAuth(
  req: express.Request,
  res: express.Response,
  next: express.NextFunction
) {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Basic ')) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  const base64Credentials = authHeader.split(' ')[1];
  const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii');
  const [username, password] = credentials.split(':');

  if (
    username !== process.env.WEBHOOK_USER ||
    password !== process.env.WEBHOOK_PASS
  ) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  next();
}

// Set para controle de idempotência
const processedTransactions = new Set<string>();

app.post('/webhooks/pix', validateBasicAuth, async (req, res) => {
  const payload: PixWebhookPayload = req.body;

  // Responder rapidamente (webhook exige resposta em até 10s)
  res.status(200).json({ acknowledged: true });

  // Verificar idempotência
  if (processedTransactions.has(payload.transactionId)) {
    console.log(`Transação ${payload.transactionId} já processada`);
    return;
  }

  // Marcar como processada
  processedTransactions.add(payload.transactionId);

  // Processar assincronamente
  try {
    switch (payload.event) {
      case 'CashIn':
        await handleCashIn(payload);
        break;
      case 'CashOut':
        await handleCashOut(payload);
        break;
      case 'CashInReversal':
        await handleCashInReversal(payload);
        break;
      case 'CashOutReversal':
        await handleCashOutReversal(payload);
        break;
    }
  } catch (error) {
    console.error(`Erro ao processar ${payload.event}:`, error);
    processedTransactions.delete(payload.transactionId);
  }
});

async function handleCashIn(payload: PixWebhookPayload) {
  console.log(`[CashIn] Recebido: R$ ${payload.finalAmount}`);
  // await orderService.markAsPaid(payload.externalId);
}

async function handleCashOut(payload: PixWebhookPayload) {
  console.log(`[CashOut] Enviado: R$ ${payload.originalAmount}`);
  // await transferService.markAsCompleted(payload.transactionId);
}

async function handleCashInReversal(payload: PixWebhookPayload) {
  console.log(`[CashInReversal] Estornado: R$ ${payload.originalAmount}`);
  // await refundService.markAsCompleted(payload.transactionId);
}

async function handleCashOutReversal(payload: PixWebhookPayload) {
  console.log(`[CashOutReversal] Devolvido: R$ ${payload.finalAmount}`);
  // await balanceService.credit(payload.finalAmount);
}

app.listen(3000);

Idempotência

Webhooks podem ser enviados mais de uma vez (em caso de retentativas). Implemente tratamento de idempotência para evitar processamento duplicado.
Use o campo transactionId como chave única:
// Verificar se já processou
const isProcessed = await redis.get(`webhook:${payload.transactionId}`);

if (isProcessed) {
  console.log('Webhook já processado, ignorando');
  return;
}

// Marcar como processado ANTES de processar
await redis.set(`webhook:${payload.transactionId}`, '1', 'EX', 86400);

// Processar webhook
await processWebhook(payload);
  • Performance: Verificação em memória é extremamente rápida
  • Distribuído: Funciona com múltiplas instâncias do servidor
  • TTL automático: Limpeza automática de registros antigos
CREATE TABLE processed_webhooks (
  transaction_id UUID PRIMARY KEY,
  processed_at TIMESTAMP DEFAULT NOW()
);

INSERT INTO processed_webhooks (transaction_id)
VALUES ($1)
ON CONFLICT (transaction_id) DO NOTHING
RETURNING transaction_id;

Boas Práticas

O sistema espera resposta em até 10 segundos. Responda imediatamente e processe de forma assíncrona para evitar timeouts.
app.post('/webhooks/pix', (req, res) => {
  res.status(200).json({ acknowledged: true });
  processWebhookAsync(req.body).catch(console.error);
});
Configure seu endpoint apenas com HTTPS para garantir transmissão segura.
Sempre valide o header Authorization com Basic Auth.
console.log({
  timestamp: new Date().toISOString(),
  event: payload.event,
  transactionId: payload.transactionId,
  amount: payload.finalAmount
});
O campo externalId contém o identificador enviado na criação. Use-o para correlacionar com seus registros.

Retentativas

Se seu endpoint não responder com HTTP 200 em até 10 segundos:
TentativaIntervaloTempo acumulado
Imediato0 min
2ª (1º retry)5 minutos5 min
3ª (2º retry)5 minutos10 min
4ª (3º retry)15 minutos25 min
Após 4 tentativas sem sucesso (tempo total ~25 minutos), o webhook é movido para uma fila de falhas (DLQ). Implemente consulta periódica como fallback para garantir que nenhuma transação seja perdida.
A estratégia de retry diferencia erros temporários (network, timeout, 5xx) de erros permanentes (validação, formato inválido). Erros permanentes não são retentados.

Códigos de Resposta

Seu endpoint deve retornar um código HTTP apropriado:
CódigoDescriçãoAção do Sistema
2xxSucesso (200, 201, 204, etc.)✅ Webhook confirmado, não será retentado
3xxRedirecionamento⚠️ Considerado falha, será retentado
4xxErro do cliente⚠️ Considerado falha, será retentado
5xxErro do servidor⚠️ Considerado falha, será retentado
O sistema valida apenas o código HTTP. Qualquer resposta 2xx (200-299) é considerada sucesso, independente do conteúdo do body. Você pode retornar body vazio, "OK", ou qualquer JSON.

Próximos Passos