Forgedocs
Webhooks

Implementação

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);
from flask import Flask, request, jsonify
from functools import wraps
import base64
import os
from typing import Dict, Any, Optional
from dataclasses import dataclass

app = Flask(__name__)
processed_transactions: set = set()

@dataclass
class PixWebhookPayload:
    event: str
    status: str
    transaction_id: str
    external_id: Optional[str]
    end_to_end_id: str
    fee_amount: float
    original_amount: float
    final_amount: float
    counterpart: Optional[Dict[str, Any]]
    parent_transaction: Optional[Dict[str, Any]]

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> 'PixWebhookPayload':
        return cls(
            event=data.get('event'),
            status=data.get('status'),
            transaction_id=data.get('transactionId'),
            external_id=data.get('externalId'),
            end_to_end_id=data.get('endToEndId'),
            fee_amount=data.get('feeAmount', 0),
            original_amount=data.get('originalAmount', 0),
            final_amount=data.get('finalAmount', 0),
            counterpart=data.get('counterpart'),
            parent_transaction=data.get('parentTransaction'),
        )


def require_basic_auth(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        auth_header = request.headers.get('Authorization')

        if not auth_header or not auth_header.startswith('Basic '):
            return jsonify({'error': 'Unauthorized'}), 401

        try:
            credentials = base64.b64decode(
                auth_header.split(' ')[1]
            ).decode('utf-8')
            username, password = credentials.split(':')

            if (
                username != os.environ.get('WEBHOOK_USER') or
                password != os.environ.get('WEBHOOK_PASS')
            ):
                return jsonify({'error': 'Invalid credentials'}), 401
        except Exception:
            return jsonify({'error': 'Invalid auth header'}), 401

        return f(*args, **kwargs)
    return decorated


@app.route('/webhooks/pix', methods=['POST'])
@require_basic_auth
def handle_pix_webhook():
    data = request.get_json()
    payload = PixWebhookPayload.from_dict(data)

    # Idempotência
    if payload.transaction_id in processed_transactions:
        return jsonify({'acknowledged': True}), 200

    processed_transactions.add(payload.transaction_id)

    # Processar
    if payload.event == 'CashIn':
        print(f"[CashIn] R$ {payload.final_amount:.2f}")
    elif payload.event == 'CashOut':
        print(f"[CashOut] R$ {payload.original_amount:.2f}")
    elif payload.event == 'CashInReversal':
        print(f"[CashInReversal] R$ {payload.original_amount:.2f}")
    elif payload.event == 'CashOutReversal':
        print(f"[CashOutReversal] R$ {payload.final_amount:.2f}")

    return jsonify({'acknowledged': True}), 200


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=3000)
<?php

$WEBHOOK_USER = getenv('WEBHOOK_USER') ?: 'goforge';
$WEBHOOK_PASS = getenv('WEBHOOK_PASS') ?: 'secret';
$PROCESSED_FILE = '/tmp/processed_transactions.json';

function validateBasicAuth($user, $pass): bool {
    $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';

    if (empty($authHeader) || !str_starts_with($authHeader, 'Basic ')) {
        return false;
    }

    $credentials = base64_decode(substr($authHeader, 6));
    list($u, $p) = explode(':', $credentials, 2);

    return $u === $user && $p === $pass;
}

function isProcessed($txId): bool {
    global $PROCESSED_FILE;
    if (!file_exists($PROCESSED_FILE)) return false;
    $processed = json_decode(file_get_contents($PROCESSED_FILE), true) ?? [];
    return in_array($txId, $processed);
}

function markProcessed($txId): void {
    global $PROCESSED_FILE;
    $processed = file_exists($PROCESSED_FILE)
        ? json_decode(file_get_contents($PROCESSED_FILE), true) ?? []
        : [];
    $processed[] = $txId;
    file_put_contents($PROCESSED_FILE, json_encode(array_slice($processed, -10000)));
}

// Validações
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit;
}

if (!validateBasicAuth($WEBHOOK_USER, $WEBHOOK_PASS)) {
    http_response_code(401);
    echo json_encode(['error' => 'Unauthorized']);
    exit;
}

$payload = json_decode(file_get_contents('php://input'), true);

// Responder rapidamente
http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['acknowledged' => true]);

if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request();
}

// Idempotência
if (isProcessed($payload['transactionId'])) {
    exit;
}

markProcessed($payload['transactionId']);

// Processar
switch ($payload['event']) {
    case 'CashIn':
        error_log("[CashIn] R$ " . $payload['finalAmount']);
        break;
    case 'CashOut':
        error_log("[CashOut] R$ " . $payload['originalAmount']);
        break;
    case 'CashInReversal':
        error_log("[CashInReversal] R$ " . $payload['originalAmount']);
        break;
    case 'CashOutReversal':
        error_log("[CashOutReversal] R$ " . $payload['finalAmount']);
        break;
}

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


Boas Práticas


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

Nesta página