docs
Webhooks

Implementación

Ejemplos Completos

Los ejemplos siguientes reciben los eventos de transacción, validan la firma X-Avista-Signature, responden 2xx rápidamente y evitan el procesamiento duplicado. La generación de la clave y los detalles de la firma están en Firma HMAC.

import crypto from 'crypto';
import express from 'express';

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

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

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

const app = express();
const SECRET = process.env.FYPAY_WEBHOOK_SECRET!;

function isValidSignature(rawBody: Buffer, received: string | undefined): boolean {
  if (!received) return false;
  const expected = crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(received, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const processed = new Set<string>();

app.post('/webhooks/pix', express.raw({ type: '*/*' }), (req, res) => {
  if (!isValidSignature(req.body, req.header('X-Avista-Signature'))) {
    return res.status(401).json({ error: 'invalid signature' });
  }

  const payload: PixWebhookPayload = JSON.parse(req.body.toString('utf8'));

  res.status(200).json({ acknowledged: true });

  const key = `${payload.event}:${payload.transactionId}:${payload.status}`;
  if (processed.has(key)) return;
  processed.add(key);

  handle(payload).catch((error) => {
    console.error(`Error al procesar ${payload.event}:`, error);
    processed.delete(key);
  });
});

async function handle(payload: PixWebhookPayload) {
  switch (payload.event) {
    case 'CashIn':
      console.log(`[CashIn] ${payload.status} R$ ${payload.finalAmount}`);
      break;
    case 'CashOut':
      console.log(`[CashOut] ${payload.status} R$ ${payload.originalAmount}`);
      break;
    case 'CashInReversal':
      console.log(`[CashInReversal] R$ ${payload.originalAmount}`);
      break;
    case 'CashOutReversal':
      console.log(`[CashOutReversal] R$ ${payload.finalAmount}`);
      break;
  }
}

app.listen(3000);
import hashlib
import hmac
import json
import os

from flask import Flask, request, jsonify

app = Flask(__name__)
SECRET = os.environ['FYPAY_WEBHOOK_SECRET'].encode('utf-8')
processed: set = set()


def is_valid_signature(raw_body: bytes, received: str) -> bool:
    if not received:
        return False
    expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)


@app.route('/webhooks/pix', methods=['POST'])
def handle_pix_webhook():
    raw_body = request.get_data()
    if not is_valid_signature(raw_body, request.headers.get('X-Avista-Signature', '')):
        return jsonify({'error': 'invalid signature'}), 401

    payload = json.loads(raw_body)

    key = f"{payload['event']}:{payload['transactionId']}:{payload['status']}"
    if key in processed:
        return jsonify({'acknowledged': True}), 200
    processed.add(key)

    event = payload['event']
    if event == 'CashIn':
        print(f"[CashIn] {payload['status']} R$ {payload['finalAmount']}")
    elif event == 'CashOut':
        print(f"[CashOut] {payload['status']} R$ {payload['originalAmount']}")
    elif event == 'CashInReversal':
        print(f"[CashInReversal] R$ {payload['originalAmount']}")
    elif event == 'CashOutReversal':
        print(f"[CashOutReversal] R$ {payload['finalAmount']}")

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


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

$SECRET = getenv('FYPAY_WEBHOOK_SECRET');
$PROCESSED_FILE = '/tmp/processed_webhooks.json';

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

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

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit;
}

$rawBody = file_get_contents('php://input');
$received = $_SERVER['HTTP_X_AVISTA_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $rawBody, $SECRET);

if ($received === '' || !hash_equals($expected, $received)) {
    http_response_code(401);
    echo json_encode(['error' => 'invalid signature']);
    exit;
}

$payload = json_decode($rawBody, true);

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

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

$key = $payload['event'] . ':' . $payload['transactionId'] . ':' . $payload['status'];
if (isProcessed($key)) {
    exit;
}
markProcessed($key);

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

Firma HMAC (X-Avista-Signature)

Después de que usted genera la clave de firma en el panel, todo webhook enviado a su cuenta incluye el header X-Avista-Signature con el HMAC-SHA256, en hexadecimal, del cuerpo bruto de la solicitud, calculado con esa clave. Validar la firma garantiza que el webhook vino de FyPay (y no de un tercero que descubrió su URL) y que el payload no fue alterado en tránsito.

El nombre del header es realmente X-Avista-Signature. Es el nombre técnico enviado hoy; úselo exactamente así en su validador (en PHP, $_SERVER['HTTP_X_AVISTA_SIGNATURE']).

La clave es por cuenta: una única clave firma todos los eventos (transacciones y MED), en todas las URLs registradas. El reenvío manual también se firma con la clave actual de la cuenta.

Mientras no se genere ninguna clave, los webhooks se entregan sin el header X-Avista-Signature. Si está integrando desde cero, genere la clave antes de poner su validador en producción y rechace las solicitudes sin el header, no solo las que traen una firma incorrecta.

Generar y rotar la clave

La clave se genera y se rota solo desde el panel (https://banking.fypay.me); no hay endpoint de API para esto.

Acceda a Configuración → Webhooks y localice la tarjeta Clave de firma HMAC.

Haga clic en Rotar y confirme su contraseña. La misma acción genera la primera clave y sustituye una clave existente.

La clave aparece una única vez, en una ventana que solo se cierra después de que usted marca la confirmación de que la copió. Después, el panel muestra solo el final de la clave (últimos 4 caracteres) y la fecha de la última rotación. FyPay no puede recuperar la clave: si la pierde, genere una nueva.

Guarde la clave en un secret manager o variable de entorno (nunca en un archivo versionado):

export FYPAY_WEBHOOK_SECRET=<clave-generada-en-el-panel>

A partir de ese momento, los próximos webhooks ya llegan con el header X-Avista-Signature.

Rotación sin rechazar webhooks en tránsito

La rotación sustituye la clave inmediatamente: los webhooks enviados a partir de ese momento usan la nueva clave. Los webhooks que ya estaban en la cola todavía pueden llegar firmados con la clave anterior. Por eso, durante el cambio, acepte las dos claves durante unos 30 minutos:

const SECRETS = [
  process.env.FYPAY_WEBHOOK_SECRET_CURRENT,
  process.env.FYPAY_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean) as string[];

function isValid(rawBody: Buffer, received: string): boolean {
  const b = Buffer.from(received, 'hex');
  return SECRETS.some((secret) => {
    const a = Buffer.from(
      crypto.createHmac('sha256', secret).update(rawBody).digest('hex'),
      'hex',
    );
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
}

Después de ese período, elimine FYPAY_WEBHOOK_SECRET_PREVIOUS. Rote la clave cuando haya sospecha de filtración o en rotaciones programadas (por ejemplo, cada 90 días).

Cómo validar

La firma cubre exactamente el cuerpo bruto de la solicitud (rawBody), en UTF-8, antes de cualquier JSON.parse. Si vuelve a serializar el objeto recibido para calcular el HMAC, cualquier divergencia (orden de claves, espacios, encoding) hará que la firma no coincida.

import crypto from 'crypto';
import express from 'express';

const app = express();

app.use('/webhooks', express.raw({ type: '*/*' }));

function isValidSignature(rawBody: Buffer, received: string, secret: string): boolean {
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(received, 'hex');
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

app.post('/webhooks/pix', (req, res) => {
  const signature = req.header('X-Avista-Signature');
  if (!signature || !isValidSignature(req.body, signature, process.env.FYPAY_WEBHOOK_SECRET!)) {
    return res.status(401).json({ error: 'invalid signature' });
  }

  const payload = JSON.parse(req.body.toString('utf8'));
  res.status(200).json({ acknowledged: true });
});
app.use(express.json({
  verify: (req, _res, buf) => {
    (req as any).rawBody = buf;
  },
}));

app.post('/webhooks/pix', (req, res) => {
  const signature = req.header('X-Avista-Signature');
  const rawBody = (req as any).rawBody as Buffer | undefined;
  if (!signature || !rawBody || !isValidSignature(rawBody, signature, secret)) {
    return res.status(401).json({ error: 'invalid signature' });
  }
  res.status(200).json({ acknowledged: true });
});
import hmac, hashlib, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ['FYPAY_WEBHOOK_SECRET']

@app.route('/webhooks/pix', methods=['POST'])
def webhook():
    raw_body = request.get_data()
    received = request.headers.get('X-Avista-Signature', '')
    if not received:
        abort(401)
    expected = hmac.new(SECRET.encode('utf-8'), raw_body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, received):
        abort(401)

    payload = request.get_json()
    return {'acknowledged': True}, 200
func validSignature(rawBody []byte, received, secret string) bool {
    if received == "" {
        return false
    }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(rawBody)
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(received))
}

http.HandleFunc("/webhooks/pix", func(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    if !validSignature(body, r.Header.Get("X-Avista-Signature"), os.Getenv("FYPAY_WEBHOOK_SECRET")) {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }
    w.WriteHeader(http.StatusOK)
})
<?php
function validSignature(string $rawBody, string $received, string $secret): bool {
    if ($received === '') return false;
    $expected = hash_hmac('sha256', $rawBody, $secret);
    return hash_equals($expected, $received);
}

$rawBody = file_get_contents('php://input');
$received = $_SERVER['HTTP_X_AVISTA_SIGNATURE'] ?? '';
if (!validSignature($rawBody, $received, getenv('FYPAY_WEBHOOK_SECRET'))) {
    http_response_code(401);
    echo json_encode(['error' => 'invalid signature']);
    exit;
}

$payload = json_decode($rawBody, true);
http_response_code(200);
echo json_encode(['acknowledged' => true]);

Use siempre una comparación timing-safe (crypto.timingSafeEqual / hmac.compare_digest / hmac.Equal / hash_equals). Comparar con == abre la puerta a ataques de timing.

Encoding: el cuerpo es UTF-8. En lenguajes que requieren bytes explícitos (Java/Kotlin/PHP), lea el stream como bytes y no lo convierta a String en otro encoding antes de calcular el HMAC. En Python, request.get_data() ya devuelve bytes; en Node, Buffer es el tipo correcto.

La firma HMAC no previene el replay. Prueba autenticidad e integridad, pero no impide que alguien que capture un webhook legítimo (proxy comprometido, log filtrado) lo reenvíe. Combine la validación de la firma con la idempotencia y con la allowlist de IPs.

Headers personalizados

Los headers personalizados registrados en la URL (hasta 5) se envían en todo webhook, junto con la firma. Si ya valida un header propio (por ejemplo, Authorization: Basic ...), puede seguir validándolo: el X-Avista-Signature es adicional y no sustituye esos headers.


Idempotencia

Los webhooks pueden entregarse más de una vez (reintentos y reenvío manual). Implemente idempotencia para evitar el procesamiento duplicado.

Para eventos de transacción, use como clave la combinación event + transactionId + status. Así, una misma transacción que cambie de estado genera un nuevo procesamiento, y las repeticiones del mismo evento se ignoran. Para eventos MED, use medId + event.

const key = `webhook:${payload.event}:${payload.transactionId}:${payload.status}`;

const isNew = await redis.set(key, '1', 'EX', 86400, 'NX');

if (!isNew) {
  console.log('Webhook ya procesado, ignorando');
  return;
}

await processWebhook(payload);


Mejores Prácticas


Reintentos

Si su endpoint no responde con 2xx en hasta 10 segundos (o si la conexión falla), la entrega se reintenta:

IntentoIntervaloTiempo acumulado
1ºInmediato0 min
2º (1.er reintento)30 segundos30 s
3º (2.º reintento)1 minuto1 min 30 s
4º (3.er reintento)3 minutos4 min 30 s
5º (4.º reintento)5 minutos9 min 30 s
6º (5.º reintento)15 minutos24 min 30 s

Después de 6 intentos sin éxito (unos 25 minutos), la entrega automática finaliza. Use el reenvío manual o consulte la transacción en la API para no perder ningún evento.

Con más de una URL registrada para el evento, los reintentos aplican solo a las URLs que fallaron. Las URLs que ya recibieron con éxito no vuelven a recibir el mismo evento.


Códigos de Respuesta

Su endpoint debe devolver un código HTTP apropiado:

CódigoDescripciónAcción del Sistema
2xxÉxito (200, 201, 204, etc.)Webhook confirmado, no se reintentará
3xxRedirecciónNo se sigue; se considera falla y se reintenta
4xxError del clienteSe considera falla y se reintenta
5xxError del servidorSe considera falla y se reintenta

El sistema evalúa solo el código HTTP. Cualquier respuesta 2xx (200-299) es éxito, independientemente del body. Puede devolver un body vacío, "OK" o cualquier JSON.


Próximos Pasos

En esta página