Webhooks
Webhooks entregam notificações push em tempo real para eventos de banking, cripto e OTC. Em vez de consultar o status por polling, a Axia entrega os eventos direto na sua aplicação sempre que uma atividade relevante acontece.
Visão geral
Webhooks = notificações bancárias em tempo real
O backend bancário emite tipos distintos de evento cobrindo pagamentos PIX, transferências TED e mudanças de status de onboarding; sobre isso, a Axia acrescenta seus próprios eventos de biometria no onboarding (onboarding-biometrics), cripto (crypto-cash-in) e OTC (otc.trade.*). A Axia abstrai a complexidade do provedor e adiciona:
- Validação de assinatura HMAC SHA256 — evita spoofing de webhook (header
X-Webhook-Signature) - Política automática de retry — até 4 tentativas com backoff exponencial (1s → 2s → 4s)
- Suporte a idempotência — cada entrega carrega um header
X-Webhook-Event-Idúnico para deduplicação - Log de entregas — toda tentativa é registrada e pode ser inspecionada no BackOffice da Axia (Auditoria → Webhooks)
Tipos de evento
| Evento | Categoria | Gatilho | Exemplo |
|---|---|---|---|
pix-payment-in | Pagamentos PIX | Usuário recebe um PIX | João recebe R$ 100 |
pix-payment-out | Pagamentos PIX | Usuário envia um PIX | Maria envia R$ 50 |
pix-reversal-in | Devoluções PIX | PIX recebido foi devolvido | R$ 100 recebidos foram devolvidos |
pix-reversal-out | Devoluções PIX | PIX enviado foi devolvido | Os R$ 50 que você enviou foram devolvidos |
spb-transfer-in | Transferências TED | Usuário recebe uma TED | Depósito de R$ 1.000 via TED |
spb-transfer-out | Transferências TED | Usuário envia uma TED | Você enviou R$ 500 por TED |
onboarding-create | Status da conta | Conta criada / KYC aprovado | KYC aprovado, conta criada |
onboarding-backgroundcheck | Status da conta | Background check concluído — inclusive a recusa, com o motivo | Proposta recusada: não foi possível confirmar os dados |
onboarding-documentscopy | Status da conta | Documentos processados | Documentos de KYC processados |
onboarding-file | Status da conta | Arquivo pronto para análise | Arquivo de KYC pronto |
onboarding-proposal | Status da conta | Status da proposta atualizado | Status da proposta mudou |
onboarding-biometrics | Status da conta | URL de verificação biométrica disponível/atualizada | Link de selfie pronto para o titular |
crypto-cash-in | Cripto | Carteira recebe um depósito onchain | Carteira recebeu 1000 USDT na Polygon |
otc.trade.settled | OTC | Um trade de OTC chega à liquidação final | BUY de 17.912 USDT liquidado, cripto entregue |
otc.trade.failed | OTC | Um trade de OTC termina em falha | SELL falhou, nenhum fiat/cripto movimentado |
Formato de entrega
Toda entrega é um POST HTTP para a URL que você cadastrou. Os metadados do evento viajam nos headers; o body da requisição é o dado do evento em si (sem envelope):
| Header | Descrição | Exemplo |
|---|---|---|
X-Webhook-Event | Tipo do evento (um dos tipos acima) | pix-payment-in |
X-Webhook-Event-Id | Id único da entrega do evento (UUID) — use para idempotência | 550e8400-e29b-41d4-a716-446655440000 |
X-Webhook-Timestamp | Momento em que o evento foi despachado (ISO 8601, UTC) | 2026-03-15T10:30:45.123Z |
X-Webhook-Signature | HMAC-SHA256 em hex do body bruto da requisição, assinado com o seu webhook secret | 9f2c4a... |
Content-Type | Sempre application/json | — |
Nota: a assinatura é o digest hex puro — não existe prefixo
sha256=. Ela é calculada sobre os bytes exatos do body bruto que você recebe; só faça o parse do body depois de validar.
Eventos bancários (pix-*, spb-*, onboarding-*)
O body é o payload do evento do provedor, como recebido pela Axia. O formato exato pode variar um pouco por provedor bancário, mas eventos de PIX/TED normalmente são assim:
{
"entity": "pix-payment-in",
"status": "COMPLETED",
"body": {
"transactionId": "E384040820262603101234567890",
"endToEndId": "E384040820262603101234567890",
"amount": 150.00,
"creditParty": {
"name": "João da Silva",
"taxId": "48059890093",
"account": "123456",
"bank": "001"
},
"debitParty": {
"name": "Maria Souza",
"taxId": "12345678901",
"account": "789012",
"bank": "001"
}
}
}Use o header X-Webhook-Event (e não campos do body) para rotear o evento, e o X-Webhook-Event-Id para deduplicação. Em pagamentos, transactionId / endToEndId também são chaves de negócio estáveis.
Eventos de onboarding (onboarding-create, onboarding-backgroundcheck, onboarding-documentscopy, onboarding-file, onboarding-proposal)
{
"entity": "onboarding-create",
"status": "CONFIRMED",
"body": {
"proposalId": "550e8400-e29b-41d4-a716-446655440001",
"documentNumber": "48059890093",
"account": {
"account": "30054029183",
"branch": "0001",
"ispb": "38404082"
}
}
}| Status do onboarding | Descrição |
|---|---|
PROCESSING | Verificação de KYC em andamento |
CONFIRMED | Conta criada com sucesso |
ERROR | Falha na criação da conta |
Evento de biometria (onboarding-biometrics)
Enviado quando a URL de verificação biométrica de um onboarding fica disponível ou muda. É a contrapartida push do polling em GET /v1/bank-accounts/checkOnboarding (que devolve o mesmo campo biometricUrl): encaminhe a URL ao titular da conta para que ele conclua a etapa de selfie/liveness.
{
"onboardingId": "550e8400-e29b-41d4-a716-446655440001",
"accountId": "30054029183",
"documentNumber": "48059890093",
"status": "PROCESSING",
"biometricUrl": "https://kyc.provider.com/biometrics/abc123"
}| Campo | Tipo | Descrição |
|---|---|---|
onboardingId | string | Id do onboarding. O formato varia conforme o provedor bancário (UUID em alguns, outros formatos em outros) — trate como string opaca. Para correlacionar com os seus registros, prefira o documentNumber; o GET /checkOnboarding aceita tanto esse id quanto o CPF/CNPJ. |
accountId | string | undefined | Id da conta bancária, quando já atribuída |
documentNumber | string | undefined | Documento do titular (CPF/CNPJ) — a chave estável para casar o onboarding com o titular da conta |
status | string | Sempre PROCESSING — o onboarding está aguardando a etapa de biometria |
biometricUrl | string | URL que o titular da conta deve abrir para concluir a verificação biométrica |
Recusa no background check (onboarding-backgroundcheck)
Enviado quando o provedor bancário recusa uma proposta durante o background check. Isso pode acontecer horas depois de o POST /v1/bank-accounts/create ter retornado com sucesso — a verificação roda de forma assíncrona.
A recusa é terminal: o onboarding não é reavaliado depois disso, então este evento é a sua notificação. Os mesmos motivos continuam disponíveis em messages[] no GET /v1/bank-accounts/checkOnboarding.
{
"onboardingId": "550e8400-e29b-41d4-a716-446655440001",
"accountId": null,
"documentNumber": "29480535000199",
"status": "ERROR",
"messages": [
{
"id": "onboarding-backgroundcheck",
"date": "2026-07-14T01:22:00.000Z",
"message": "Não conseguimos confirmar algumas das informações da sua proposta, para mais informações entre em contato com o SAC!",
"status": "proposal-reproved",
"actionRequired": false
}
]
}| Campo | Tipo | Descrição |
|---|---|---|
onboardingId | string | Id do onboarding — trate como opaco; prefira o documentNumber para correlacionar |
accountId | string | undefined | Id da conta bancária, quando já atribuída |
documentNumber | string | undefined | Documento do titular (CPF/CNPJ) — a chave estável para casar o onboarding com o titular da conta |
status | string | Sempre ERROR — igual ao status retornado pelo GET /checkOnboarding |
messages | array | Motivos informados pelo provedor. messages[].message é o texto a exibir para o titular da conta; actionRequired indica se ele pode corrigir e reenviar; url traz o link da ação quando fornecido |
Eventos de cripto (crypto-cash-in)
Enviado quando uma carteira do tenant recebe um depósito onchain. O valor é entregue como string decimal (precisão onchain completa); txId é o hash da transação onchain — use-o para idempotência.
{
"address": "0x1122334455667788990011223344556677889900",
"amount": "1000.00",
"asset": "USDT",
"txId": "0xdeadbeef...",
"chain": "polygon-mainnet",
"network": "EVM",
"walletId": "a1b2c3d4-0000-0000-0000-000000000000",
"type": "token"
}| Campo | Tipo | Descrição |
|---|---|---|
address | string | Endereço da carteira que recebeu |
amount | string | Valor depositado (string decimal, precisão completa) |
asset | string | Símbolo do ativo (ex.: USDT, MATIC) |
txId | string | Hash da transação onchain — use para idempotência |
chain | string | Id da chain no provedor (ex.: polygon-mainnet) |
network | string | Família da chain (EVM | BTC | TRON | SOLANA | XRPL) |
walletId | string | Identificador da carteira no registro |
type | string | native | token |
Eventos de OTC (otc.trade.settled, otc.trade.failed)
Enviado quando um trade de OTC criado por você pela API de OTC chega a um estado terminal. Esta é a alternativa push ao polling em GET /v1/otc/trades/{id} até SETTLED — assine estes eventos em vez de fazer long-polling.
otc.trade.settled— o trade foi totalmente liquidado (BUY: cripto entregue na carteira de destino;SELL: cripto movida da sua carteira custodial e BRL pago). OtxHashtraz o hash onchain.otc.trade.failed— o trade terminou em falha. Nenhuma movimentação adicional ocorre;txHashvemnull. NumBUYpago por QR (paymentMethod: PIX_QRCODE), a falha também cobrePIX_IN_EXPIRED(QR não pago no prazo) ePIX_IN_PAYER_MISMATCH(PIX pago por um documento que não é o CNPJ do seu tenant — o valor é devolvido manualmente pelo suporte).
O objeto do trade é o body da requisição diretamente, seguindo o mesmo formato de entrega de todos os outros eventos. Use o id do trade (ou o X-Webhook-Event-Id) para idempotência.
Body (liquidado):
{
"id": "a1b2c3d4-...",
"side": "BUY",
"asset": "USDT",
"quoteCurrency": "BRL",
"volume": 17912.5,
"unitPrice": 5.5825,
"totalAmount": 100000,
"walletAddress": "TXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"walletNetwork": "TRON",
"status": "SETTLED",
"txHash": "0xabc...",
"createdAt": "2026-06-18T12:00:00.000Z",
"executedAt": "2026-06-18T12:03:00.000Z"
}Body (falha): mesmo formato, com "status": "FAILED", "txHash": null, "executedAt": null.
| Campo | Tipo | Descrição |
|---|---|---|
id | string | Id do trade de OTC — igual ao id retornado pelo POST /v1/otc/trades. Use para idempotência. |
side | string | BUY (onramp) | SELL (offramp) |
asset | string | Ativo negociado (ex.: USDT) |
quoteCurrency | string | Moeda fiat do trade (ex.: BRL) |
volume | number | Quantidade de cripto negociada |
unitPrice | number | Preço final aplicado por unidade, em quoteCurrency |
totalAmount | number | Valor total em fiat |
walletAddress | string | Carteira de destino (BUY) / carteira custodial de origem (SELL) |
walletNetwork | string | TRON | EVM | POLYGON | ETHEREUM |
status | string | SETTLED | FAILED |
txHash | string | null | Hash onchain quando liquidado; null em caso de falha |
createdAt | ISO 8601 | Quando o trade foi criado |
executedAt | ISO 8601 | null | Quando foi liquidado; null em caso de falha |
O payload é neutro em relação ao provedor por design — ele nunca expõe a origem da liquidez, o custo ou a margem, apenas o preço final aplicável ao seu tenant.
Gerenciar webhooks
Todas as rotas de gerenciamento são autenticadas com o seu token OAuth Bearer de sempre (header Authorization), como qualquer outra rota da API. As assinaturas são escopadas ao seu tenant. O secret do webhook é write-only — ele é armazenado para assinar as entregas e nunca é retornado por nenhum endpoint.
Cadastrar um webhook
POST /v1/webhooks
curl -X POST https://baas-gtw.axiadigitalsolutions.com/v1/webhooks \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/baas",
"events": ["pix-payment-in", "pix-payment-out", "onboarding-create", "onboarding-biometrics"],
"secret": "whsec_your_secret_key_min_32_chars_long"
}'Body da requisição:
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
url | string | Sim | URL do endpoint HTTPS para onde a Axia envia os eventos (HTTP é rejeitado) |
events | array | Sim | Lista não-vazia de tipos de evento a assinar (da tabela acima) |
secret | string | Sim | Segredo compartilhado para validação da assinatura HMAC (mín. 32 caracteres). Write-only. |
Response — 201 Created:
{
"webhookId": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://your-app.com/webhooks/baas",
"events": ["pix-payment-in", "pix-payment-out", "onboarding-create", "onboarding-biometrics"],
"status": "active",
"createdAt": "2026-03-15T10:00:00Z",
"updatedAt": "2026-03-15T10:00:00Z"
}Você pode cadastrar múltiplos webhooks (ex.: URLs diferentes para grupos diferentes de evento); cada evento é entregue a toda assinatura ativa que inclua o seu tipo.
Listar webhooks
GET /v1/webhooks
curl https://baas-gtw.axiadigitalsolutions.com/v1/webhooks \
-H "Authorization: Bearer $TOKEN"Response — 200 OK:
{
"data": [
{
"webhookId": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://your-app.com/webhooks/baas",
"events": ["pix-payment-in", "onboarding-create"],
"status": "active",
"createdAt": "2026-03-15T10:00:00Z",
"updatedAt": "2026-03-15T10:00:00Z"
}
]
}Atualizar um webhook
PUT /v1/webhooks/{webhookId}
Atualiza a assinatura parcialmente — envie apenas os campos que quiser alterar (url, events, secret, active).
curl -X PUT https://baas-gtw.axiadigitalsolutions.com/v1/webhooks/550e8400-e29b-41d4-a716-446655440000 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"events": ["pix-payment-in", "pix-payment-out", "crypto-cash-in"],
"active": true
}'Response — 200 OK: o webhook atualizado (mesmo formato do cadastro). Definir "active": false pausa as entregas sem apagar a assinatura.
Excluir um webhook
DELETE /v1/webhooks/{webhookId}
curl -X DELETE https://baas-gtw.axiadigitalsolutions.com/v1/webhooks/550e8400-e29b-41d4-a716-446655440000 \
-H "Authorization: Bearer $TOKEN"Response — 200 OK:
{
"webhookId": "550e8400-e29b-41d4-a716-446655440000",
"status": "deleted",
"deletedAt": "2026-03-15T10:45:00Z"
}Validação da assinatura (CRÍTICO)
Toda entrega inclui um header X-Webhook-Signature: o HMAC-SHA256 em hex do body bruto da requisição, assinado com o seu webhook secret. Você DEVE validar essa assinatura para prevenir ataques de spoofing.
Formato do header
X-Webhook-Signature: <hex-encoded-hmac-sha256>Digest hex puro — sem prefixo sha256=.
Algoritmo de validação
import crypto from 'crypto';
function validateSignature(
rawBody: string,
signature: string,
secret: string
): boolean {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// Comparação timing-safe para prevenir timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
);
} catch {
return false; // Tamanho divergente / hex inválido
}
}Importante: calcule o HMAC sobre os bytes brutos exatos do body da requisição — só faça o parse do JSON depois de validar. Re-serializar um objeto já parseado pode mudar a ordem das chaves/espaçamento e quebrar a comparação.
Implementação em Express.js
import express from 'express';
import crypto from 'crypto';
const app = express();
const webhookSecret = process.env.WEBHOOK_SECRET;
app.post(
'/webhooks/baas',
express.raw({ type: 'application/json' }),
(req, res) => {
try {
const signature = req.header('X-Webhook-Signature');
const rawBody = req.body.toString('utf8');
if (!signature) {
return res.status(401).json({ error: 'Missing signature header' });
}
if (!validateSignature(rawBody, signature, webhookSecret)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const eventType = req.header('X-Webhook-Event');
const eventId = req.header('X-Webhook-Event-Id');
const event = JSON.parse(rawBody);
// Processe o evento (veja a seção de idempotência)
res.status(200).json({ received: true });
} catch (err) {
console.error('Webhook processing error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);Implementação em Python (Flask)
import hmac
import hashlib
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
webhook_secret = os.getenv('WEBHOOK_SECRET')
def validate_signature(payload: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
@app.route('/webhooks/baas', methods=['POST'])
def webhook():
try:
signature = request.headers.get('X-Webhook-Signature')
raw_body = request.get_data()
if not signature or not validate_signature(raw_body, signature, webhook_secret):
return jsonify({'error': 'Invalid signature'}), 401
event_type = request.headers.get('X-Webhook-Event')
event_id = request.headers.get('X-Webhook-Event-Id')
event = request.get_json()
# Processe o evento
return jsonify({'received': True}), 200
except Exception as err:
logging.error(f'Webhook error: {err}')
return jsonify({'error': 'Internal server error'}), 500Política de retry
A Axia repete automaticamente as entregas de webhook que falham. Uma entrega é considerada bem-sucedida se o seu endpoint responder com status HTTP 200–299 em até 30 segundos.
Cronograma de retry
| Tentativa | Espera após a falha anterior |
|---|---|
| 1ª | Imediata |
| 2ª | +1 segundo |
| 3ª | +2 segundos |
| 4ª | +4 segundos |
| Após a 4ª falha | Entrega marcada como FAILED no log de entregas |
Os retries são curtos por design: eles cobrem instabilidades pontuais de rede, não quedas longas. Se o seu endpoint ficar fora do ar por mais tempo, recupere o estado perdido pelas APIs de consulta (ex.:
GET /v1/otc/trades/{id},GET /v1/bank-accounts/checkOnboarding) — todo webhook tem uma contrapartida por polling. As entregas que falharam ficam visíveis no BackOffice (Auditoria → Webhooks).
Boas práticas para webhooks
O seu endpoint deve:
- Responder rápido (< 30 segundos) — valide a assinatura, confirme o recebimento e depois processe em background
- Retornar 2xx imediatamente — não espere gravações no banco nem chamadas a APIs de terceiros
- Lidar bem com timeouts — se o processamento levar > 30s, o seu endpoint pode ser chamado de novo
- Implementar idempotência — use o header
X-Webhook-Event-Idcomo chave única (veja a próxima seção)
Exemplo: confirmação rápida
app.post(
'/webhooks/baas',
express.raw({ type: 'application/json' }),
async (req, res) => {
try {
const signature = req.header('X-Webhook-Signature');
const rawBody = req.body.toString('utf8');
if (!validateSignature(rawBody, signature, webhookSecret)) {
return res.status(401).send('Unauthorized');
}
const event = {
id: req.header('X-Webhook-Event-Id'),
type: req.header('X-Webhook-Event'),
data: JSON.parse(rawBody)
};
// Responda imediatamente
res.status(202).send('Accepted');
// Processe em background (sem await)
processWebhookAsync(event).catch(err => {
console.error('Background webhook processing failed:', err);
});
} catch (err) {
console.error('Webhook error:', err);
res.status(500).send('Error');
}
}
);Idempotência em webhooks
Instabilidade de rede ou retries podem fazer o mesmo evento ser entregue mais de uma vez. Toda entrega carrega um header X-Webhook-Event-Id único — use-o como chave para evitar processamento duplicado. Também há chaves de negócio por tipo de evento (transactionId/endToEndId para pagamentos, txId para cripto, id do trade para OTC).
Implementação de idempotência
async function processWebhook(event: { id: string; type: string; data: any }) {
const existingEvent = await db.webhookEvent.findUnique({
where: { eventId: event.id }
});
if (existingEvent) {
console.log(`Webhook ${event.id} already processed, skipping`);
return; // Já tratado
}
// Marque como em processamento
await db.webhookEvent.create({
data: {
eventId: event.id,
eventType: event.type,
payload: event.data,
processedAt: new Date()
}
});
// Execute a regra de negócio
switch (event.type) {
case 'pix-payment-in':
await updateBalance(event.data.body, 'credit');
break;
case 'onboarding-biometrics':
await notifyHolder(event.data.onboardingId, event.data.biometricUrl);
break;
case 'onboarding-create':
await updateAccountStatus(event.data);
break;
// ... outros tipos de evento
}
}Schema de banco (Prisma)
model WebhookEvent {
id String @id @default(cuid())
eventId String @unique // X-Webhook-Event-Id
eventType String
payload Json
processedAt DateTime @default(now())
@@index([eventId])
}Log de entregas
Toda tentativa de entrega (bem-sucedida ou não) é registrada com a URL de destino, o status HTTP, o body da resposta e o número da tentativa. Inspecione o log no BackOffice da Axia em Auditoria → Webhooks — útil para depurar um endpoint que está rejeitando entregas, confirmar o que foi enviado e identificar entregas FAILED a reconciliar pelas APIs de consulta.
Exemplo completo ponta a ponta
Setup com Node.js + Express
import express from 'express';
import crypto from 'crypto';
import { PrismaClient } from '@prisma/client';
const app = express();
const db = new PrismaClient();
const webhookSecret = process.env.WEBHOOK_SECRET;
if (!webhookSecret || webhookSecret.length < 32) {
throw new Error('WEBHOOK_SECRET must be >= 32 characters');
}
// ============================================================================
// Validação da assinatura
// ============================================================================
function validateSignature(
rawBody: string,
signature: string,
secret: string
): boolean {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
try {
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
);
} catch {
return false;
}
}
// ============================================================================
// Handler do webhook
// ============================================================================
app.post(
'/webhooks/baas',
express.raw({ type: 'application/json' }),
async (req, res) => {
try {
const sig = req.header('X-Webhook-Signature');
const rawBody = req.body.toString('utf8');
// Valide a assinatura
if (!sig || !validateSignature(rawBody, sig, webhookSecret)) {
console.warn('Invalid webhook signature');
return res.status(401).json({ error: 'Unauthorized' });
}
const event = {
id: req.header('X-Webhook-Event-Id') || crypto.randomUUID(),
type: req.header('X-Webhook-Event') || 'generic',
timestamp: req.header('X-Webhook-Timestamp'),
data: JSON.parse(rawBody)
};
// Confirme o recebimento imediatamente
res.status(202).json({ status: 'accepted' });
// Processe em background
processWebhookAsync(event).catch(err => {
console.error('Background processing failed:', err);
});
} catch (err) {
console.error('Webhook endpoint error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
// ============================================================================
// Processamento em background
// ============================================================================
interface WebhookEvent {
id: string;
type: string;
timestamp?: string;
data: any;
}
async function processWebhookAsync(event: WebhookEvent) {
console.log(`Processing webhook: ${event.id} (${event.type})`);
// Verifique a idempotência
const existing = await db.webhookEvent.findUnique({
where: { eventId: event.id }
});
if (existing) {
console.log(`Webhook ${event.id} already processed`);
return;
}
// Registre o recebimento
await db.webhookEvent.create({
data: {
eventId: event.id,
eventType: event.type,
payload: event.data,
processedAt: new Date()
}
});
// Trate por tipo de evento
switch (event.type) {
case 'pix-payment-in':
case 'spb-transfer-in':
await handleIncomingPayment(event);
break;
case 'pix-payment-out':
case 'spb-transfer-out':
await handleOutgoingPayment(event);
break;
case 'pix-reversal-in':
case 'pix-reversal-out':
await handleReversal(event);
break;
case 'onboarding-biometrics':
await handleBiometrics(event);
break;
case 'onboarding-create':
case 'onboarding-backgroundcheck':
case 'onboarding-documentscopy':
case 'onboarding-file':
case 'onboarding-proposal':
await handleOnboardingUpdate(event);
break;
case 'crypto-cash-in':
await handleCryptoDeposit(event);
break;
case 'otc.trade.settled':
case 'otc.trade.failed':
await handleOtcTrade(event);
break;
default:
console.warn(`Unknown event type: ${event.type}`);
}
console.log(`✓ Webhook ${event.id} processed successfully`);
}
async function handleIncomingPayment(event: WebhookEvent) {
const body = event.data?.body || event.data;
const accountId = body?.creditParty?.account;
await db.transaction.create({
data: {
accountId,
type: 'CREDIT',
amount: body.amount,
description: `PIX received from ${body?.debitParty?.name || 'unknown'}`,
externalId: body.transactionId,
externalEndToEndId: body.endToEndId
}
});
}
async function handleOutgoingPayment(event: WebhookEvent) {
const body = event.data?.body || event.data;
const accountId = body?.debitParty?.account;
await db.transaction.create({
data: {
accountId,
type: 'DEBIT',
amount: body.amount,
description: `PIX sent to ${body?.creditParty?.name || 'unknown'}`,
externalId: body.transactionId,
externalEndToEndId: body.endToEndId
}
});
}
async function handleReversal(event: WebhookEvent) {
const body = event.data?.body || event.data;
const isInbound = event.type === 'pix-reversal-in';
await db.transaction.create({
data: {
accountId: isInbound ? body?.creditParty?.account : body?.debitParty?.account,
type: isInbound ? 'DEBIT' : 'CREDIT',
amount: body.amount,
description: `PIX reversal (${event.type})`,
externalId: body.transactionId
}
});
}
async function handleBiometrics(event: WebhookEvent) {
const { onboardingId, biometricUrl } = event.data;
// Entregue o link de verificação ao titular da conta
await db.onboarding.update({
where: { onboardingId },
data: { biometricUrl }
});
await sendPushNotification(
onboardingId,
'Identity verification required',
'Tap to complete your biometric verification'
);
}
async function handleOnboardingUpdate(event: WebhookEvent) {
const body = event.data?.body || event.data;
const status = event.data?.status || body?.status;
await db.onboarding.update({
where: { onboardingId: body?.proposalId },
data: { status }
});
if (status === 'CONFIRMED') {
await sendPushNotification(
body?.proposalId,
'Account Approved',
'Your account is now ready to use'
);
}
}
async function handleCryptoDeposit(event: WebhookEvent) {
const { address, amount, asset, txId } = event.data;
console.log(`Crypto deposit: ${amount} ${asset} on ${address} (${txId})`);
}
async function handleOtcTrade(event: WebhookEvent) {
const trade = event.data;
console.log(`OTC trade ${trade.id}: ${trade.status}`);
}
async function sendPushNotification(
ref: string,
title: string,
body: string
) {
// Integre com o seu serviço de push notification
console.log(`📱 Notification (${ref}): ${title}`);
}
// ============================================================================
// Servidor
// ============================================================================
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Webhook server listening on port ${PORT}`);
console.log(`Webhook endpoint: POST http://localhost:${PORT}/webhooks/baas`);
});
export default app;Testando webhooks
Usando ferramentas de teste de webhook
Ferramentas populares para testar webhooks localmente:
- Webhook.cool — URL gerada que captura as requisições
- ngrok — expõe o servidor local para a internet com HTTPS
- Beeceptor — mock de API + inspeção de webhook
- Postman — envie requisições de webhook manualmente
Teste local com ngrok
# Exponha o endpoint de webhook local para a internet
ngrok http 3000
# Saída: https://abcd-1234.ngrok.io
# Cadastre o webhook:
curl -X POST https://baas-gtw.axiadigitalsolutions.com/v1/webhooks \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://abcd-1234.ngrok.io/webhooks/baas",
"events": ["pix-payment-in"],
"secret": "whsec_test_1234567890123456789012345678"
}'
# Agora teste a entrega do webhook a partir da AxiaTeste unitário (Jest)
import request from 'supertest';
import crypto from 'crypto';
import app from './app';
const webhookSecret = 'test-secret-key-32-chars-minimum!!';
function signPayload(payload: object, secret: string): string {
const payloadString = JSON.stringify(payload);
return crypto
.createHmac('sha256', secret)
.update(payloadString)
.digest('hex');
}
describe('Webhook Endpoint', () => {
it('should accept valid webhook with correct signature', async () => {
const payload = {
body: {
transactionId: 'E384040820262603101234567890',
amount: 100.00,
creditParty: { account: '30054029183' }
}
};
const signature = signPayload(payload, webhookSecret);
const response = await request(app)
.post('/webhooks/baas')
.set('X-Webhook-Signature', signature)
.set('X-Webhook-Event', 'pix-payment-in')
.set('X-Webhook-Event-Id', 'evt_test_123')
.send(payload);
expect(response.status).toBe(202);
expect(response.body.status).toBe('accepted');
});
it('should reject webhook with invalid signature', async () => {
const response = await request(app)
.post('/webhooks/baas')
.set('X-Webhook-Signature', 'deadbeef')
.set('X-Webhook-Event', 'pix-payment-in')
.send({ body: { amount: 100.00 } });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Unauthorized');
});
it('should handle idempotent duplicate deliveries', async () => {
const payload = { body: { amount: 50.00 } };
const signature = signPayload(payload, webhookSecret);
const send = () => request(app)
.post('/webhooks/baas')
.set('X-Webhook-Signature', signature)
.set('X-Webhook-Event', 'pix-payment-in')
.set('X-Webhook-Event-Id', 'evt_idempotent_test')
.send(payload);
// Primeira entrega
const res1 = await send();
expect(res1.status).toBe(202);
// Entrega duplicada (mesmo X-Webhook-Event-Id)
const res2 = await send();
expect(res2.status).toBe(202); // Continua aceita, mas não é reprocessada
});
});Checklist de segurança
- [ ] Use sempre HTTPS nas URLs de webhook (cadastros em HTTP são rejeitados)
- [ ] Valide o header
X-Webhook-Signatureem toda requisição - [ ] Use
crypto.timingSafeEqual()para prevenir timing attacks - [ ] Guarde o
WEBHOOK_SECRETcom segurança (variável de ambiente, nunca no código) - [ ] Implemente idempotência com o
X-Webhook-Event-Idpara evitar processamento duplicado - [ ] Responda rápido (< 30s) e processe o trabalho pesado de forma assíncrona
- [ ] Registre todas as entregas de webhook para trilha de auditoria
- [ ] Monitore as entregas que falharam no BackOffice (Auditoria → Webhooks)
- [ ] Rotacione o webhook secret periodicamente (PUT
/v1/webhooks/{webhookId}com um novosecret) - [ ] Aplique rate-limit no processamento dos webhooks para prevenir DoS
Tratamento de erros
Problemas comuns
| Problema | Causa | Solução |
|---|---|---|
401 Unauthorized | Assinatura inválida | Confira se o secret confere e calcule o HMAC sobre os bytes do body bruto (sem prefixo sha256=) |
502 Bad Gateway | Timeout do endpoint (> 30s) | Mova o processamento para um job em background e retorne 202 mais rápido |
Webhook nunca entregue | URL inválida ou falha de DNS | Teste o endpoint com curl, verifique firewall/NAT |
Eventos duplicados | Retry de rede ou ACK atrasado | Implemente idempotência com o X-Webhook-Event-Id |
Eventos faltando | Webhook não cadastrado ou evento não assinado | Verifique a assinatura em GET /v1/webhooks (URL, events, status: active) |
Suporte
Para problemas com webhooks:
- Consulte o BackOffice em Auditoria → Webhooks para ver os logs de entrega
- Habilite logging verboso na sua aplicação
- Teste com o Webhook.cool para capturar as requisições brutas