Implementation
Complete Examples
The examples below receive transaction events, validate the X-Avista-Signature signature, respond 2xx quickly and avoid duplicate processing. Key generation and signature details are in HMAC Signature.
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 processing ${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;
}HMAC Signature (X-Avista-Signature)
After you generate the signing key in the dashboard, every webhook sent to your account includes the X-Avista-Signature header with the HMAC-SHA256, in hexadecimal, of the raw request body, computed with that key. Validating the signature guarantees that the webhook came from FyPay (and not from a third party who discovered your URL) and that the payload was not altered in transit.
The header name really is X-Avista-Signature. This is the technical name sent today; use it exactly like that in your validator (in PHP, $_SERVER['HTTP_X_AVISTA_SIGNATURE']).
The key is per account: a single key signs all events (transactions and MED), on all registered URLs. The manual resend is also signed with the account's current key.
Until a key is generated, webhooks are delivered without the X-Avista-Signature header. If you are integrating from scratch, generate the key before putting your validator in production and reject requests without the header, not just those with an incorrect signature.
Generating and rotating the key
The key is generated and rotated only in the dashboard (https://banking.fypay.me); there is no API endpoint for it.
Go to Settings → Webhooks and find the HMAC signing key card.
Click Rotate and confirm your password. The same action generates the first key and replaces an existing key.
The key appears only once, in a window that closes only after you check the confirmation that you copied it. After that, the dashboard shows only the end of the key (last 4 characters) and the date of the last rotation. FyPay cannot recover the key: if you lose it, generate a new one.
Store the key in a secret manager or environment variable (never in a versioned file):
export FYPAY_WEBHOOK_SECRET=<key-generated-in-the-dashboard>From that moment on, the next webhooks already arrive with the X-Avista-Signature header.
Rotation without rejecting in-flight webhooks
Rotation replaces the key immediately: webhooks sent from then on use the new key. Webhooks that were already queued may still arrive signed with the previous key. Therefore, during the switch, accept both keys for about 30 minutes:
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);
});
}After that period, remove FYPAY_WEBHOOK_SECRET_PREVIOUS. Rotate when a leak is suspected or on scheduled rotations (for example, every 90 days).
How to validate
The signature covers exactly the raw request body (rawBody), in UTF-8, before any JSON.parse. If you re-serialize the received object to compute the HMAC, any difference (key order, whitespace, encoding) will make the signature not match.
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}, 200func 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]);Always use timing-safe comparison (crypto.timingSafeEqual / hmac.compare_digest / hmac.Equal / hash_equals). Comparing with == opens the door to timing attacks.
Encoding: the body is UTF-8. In languages that need explicit bytes (Java/Kotlin/PHP), read the stream as bytes and do not convert it to a String in another encoding before computing the HMAC. In Python, request.get_data() already returns bytes; in Node, Buffer is the correct type.
The HMAC signature does not prevent replay. It proves authenticity and integrity, but does not prevent someone who captures a legitimate webhook (compromised proxy, leaked log) from resending it. Combine signature validation with idempotency and with the IP allowlist.
Custom headers
The custom headers registered on the URL (up to 5) are sent with every webhook, along with the signature. If you already validate your own header (for example, Authorization: Basic ...), you can keep validating it: X-Avista-Signature is additional and does not replace those headers.
Idempotency
Webhooks may be delivered more than once (retries and manual resend). Implement idempotency to avoid duplicate processing.
For transaction events, use the combination event + transactionId + status as the key. This way, a transaction that changes status triggers new processing, and repetitions of the same event are ignored. For MED events, 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 already processed, ignoring');
return;
}
await processWebhook(payload);Best Practices
Retries
If your endpoint does not respond with 2xx within 10 seconds (or if the connection fails), the delivery is retried:
| Attempt | Interval | Cumulative time |
|---|---|---|
| 1st | Immediate | 0 min |
| 2nd (1st retry) | 30 seconds | 30 s |
| 3rd (2nd retry) | 1 minute | 1 min 30 s |
| 4th (3rd retry) | 3 minutes | 4 min 30 s |
| 5th (4th retry) | 5 minutes | 9 min 30 s |
| 6th (5th retry) | 15 minutes | 24 min 30 s |
After 6 unsuccessful attempts (about 25 minutes), automatic delivery ends. Use the manual resend or query the transaction through the API so you don't miss any event.
With more than one URL registered for the event, retries apply only to the URLs that failed. URLs that have already received successfully do not receive the same event again.
Response Codes
Your endpoint should return an appropriate HTTP code:
| Code | Description | System Action |
|---|---|---|
2xx | Success (200, 201, 204, etc.) | Webhook confirmed, will not be retried |
3xx | Redirect | Not followed; considered a failure and retried |
4xx | Client error | Considered a failure and retried |
5xx | Server error | Considered a failure and retried |
The system evaluates only the HTTP code. Any 2xx response (200-299) is success, regardless of the body. You can return an empty body, "OK" or any JSON.