Implementação
Exemplos Completos
Os exemplos abaixo recebem os eventos de transação, validam a assinatura X-Avista-Signature, respondem 2xx rapidamente e evitam processamento duplicado. A geração da chave e os detalhes da assinatura estão em Assinatura 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(`Erro ao processar ${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;
}Assinatura HMAC (X-Avista-Signature)
Depois que você gera a chave de assinatura no painel, todo webhook enviado para a sua conta inclui o header X-Avista-Signature com o HMAC-SHA256, em hexadecimal, do corpo bruto da requisição, calculado com essa chave. Validar a assinatura garante que o webhook veio da FyPay (e não de um terceiro que descobriu sua URL) e que o payload não foi alterado em trânsito.
O nome do header é mesmo X-Avista-Signature. Esse é o nome técnico enviado hoje; use-o exatamente assim no seu validador (em PHP, $_SERVER['HTTP_X_AVISTA_SIGNATURE']).
A chave é por conta: uma única chave assina todos os eventos (transações e MED), em todas as URLs cadastradas. O reenvio manual também é assinado com a chave atual da conta.
Enquanto nenhuma chave for gerada, os webhooks são entregues sem o header X-Avista-Signature. Se você está integrando do zero, gere a chave antes de colocar seu validador em produção e rejeite requisições sem o header, não apenas as que trazem assinatura incorreta.
Gerando e rotacionando a chave
A chave é gerada e rotacionada somente pelo painel (https://banking.fypay.me); não há endpoint de API para isso.
Acesse Configurações → Webhooks e localize o card Chave de assinatura HMAC.
Clique em Rotacionar e confirme sua senha. A mesma ação gera a primeira chave e substitui uma chave existente.
A chave aparece uma única vez, em uma janela que só fecha depois que você marca a confirmação de que a copiou. Depois disso, o painel mostra apenas o final da chave (últimos 4 caracteres) e a data da última rotação. A FyPay não consegue recuperar a chave: se perdê-la, gere uma nova.
Guarde a chave em um secret manager ou variável de ambiente (nunca em arquivo versionado):
export FYPAY_WEBHOOK_SECRET=<chave-gerada-no-painel>A partir desse momento, os próximos webhooks já chegam com o header X-Avista-Signature.
Rotação sem rejeitar webhooks em trânsito
A rotação substitui a chave imediatamente: os webhooks enviados a partir daí usam a nova chave. Webhooks que já estavam na fila podem ainda chegar assinados com a chave anterior. Por isso, durante a troca, aceite as duas chaves por cerca de 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);
});
}Depois desse período, remova FYPAY_WEBHOOK_SECRET_PREVIOUS. Rotacione quando houver suspeita de vazamento ou em rotações programadas (por exemplo, a cada 90 dias).
Como validar
A assinatura cobre exatamente o corpo bruto da requisição (rawBody), em UTF-8, antes de qualquer JSON.parse. Se você reserializar o objeto recebido para calcular o HMAC, qualquer divergência (ordem de chaves, espaços, encoding) fará a assinatura não bater.
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]);Use sempre comparação timing-safe (crypto.timingSafeEqual / hmac.compare_digest / hmac.Equal / hash_equals). Comparar com == abre brecha para ataques de timing.
Encoding: o corpo é UTF-8. Em linguagens que precisam de bytes explícitos (Java/Kotlin/PHP), leia o stream como bytes e não converta para String em outro encoding antes de calcular o HMAC. Em Python, request.get_data() já retorna bytes; em Node, Buffer é o tipo correto.
A assinatura HMAC não previne replay. Ela prova autenticidade e integridade, mas não impede que alguém que capture um webhook legítimo (proxy comprometido, log vazado) o reenvie. Combine a validação da assinatura com a idempotência e com a allowlist de IPs.
Headers customizados
Os headers customizados cadastrados na URL (até 5) são enviados em todo webhook, junto com a assinatura. Se você já valida um header próprio (por exemplo, Authorization: Basic ...), pode continuar validando: o X-Avista-Signature é adicional e não substitui esses headers.
Idempotência
Webhooks podem ser entregues mais de uma vez (retentativas e reenvio manual). Implemente idempotência para evitar processamento duplicado.
Para eventos de transação, use como chave a combinação event + transactionId + status. Assim, uma mesma transação que mude de status gera um novo processamento, e repetições do mesmo evento são ignoradas. 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 já processado, ignorando');
return;
}
await processWebhook(payload);Boas Práticas
Retentativas
Se o seu endpoint não responder com 2xx em até 10 segundos (ou se a conexão falhar), a entrega é retentada:
| Tentativa | Intervalo | Tempo acumulado |
|---|---|---|
| 1ª | Imediato | 0 min |
| 2ª (1º retry) | 30 segundos | 30 s |
| 3ª (2º retry) | 1 minuto | 1 min 30 s |
| 4ª (3º retry) | 3 minutos | 4 min 30 s |
| 5ª (4º retry) | 5 minutos | 9 min 30 s |
| 6ª (5º retry) | 15 minutos | 24 min 30 s |
Após 6 tentativas sem sucesso (cerca de 25 minutos), a entrega automática é encerrada. Use o reenvio manual ou consulte a transação na API para não perder nenhum evento.
Com mais de uma URL cadastrada para o evento, as retentativas valem só para as URLs que falharam. URLs que já receberam com sucesso não recebem o mesmo evento de novo.
Códigos de Resposta
Seu endpoint deve retornar um código HTTP apropriado:
| Código | Descrição | Ação do Sistema |
|---|---|---|
2xx | Sucesso (200, 201, 204, etc.) | Webhook confirmado, não será retentado |
3xx | Redirecionamento | Não é seguido; considerado falha e retentado |
4xx | Erro do cliente | Considerado falha e retentado |
5xx | Erro do servidor | Considerado falha e retentado |
O sistema avalia apenas o código HTTP. Qualquer resposta 2xx (200-299) é sucesso, independente do body. Você pode retornar body vazio, "OK" ou qualquer JSON.