Skip to content

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 ​

EventoCategoriaGatilhoExemplo
pix-payment-inPagamentos PIXUsuário recebe um PIXJoão recebe R$ 100
pix-payment-outPagamentos PIXUsuário envia um PIXMaria envia R$ 50
pix-reversal-inDevoluções PIXPIX recebido foi devolvidoR$ 100 recebidos foram devolvidos
pix-reversal-outDevoluções PIXPIX enviado foi devolvidoOs R$ 50 que você enviou foram devolvidos
spb-transfer-inTransferências TEDUsuário recebe uma TEDDepósito de R$ 1.000 via TED
spb-transfer-outTransferências TEDUsuário envia uma TEDVocê enviou R$ 500 por TED
onboarding-createStatus da contaConta criada / KYC aprovadoKYC aprovado, conta criada
onboarding-backgroundcheckStatus da contaBackground check concluído — inclusive a recusa, com o motivoProposta recusada: não foi possível confirmar os dados
onboarding-documentscopyStatus da contaDocumentos processadosDocumentos de KYC processados
onboarding-fileStatus da contaArquivo pronto para análiseArquivo de KYC pronto
onboarding-proposalStatus da contaStatus da proposta atualizadoStatus da proposta mudou
onboarding-biometricsStatus da contaURL de verificação biométrica disponível/atualizadaLink de selfie pronto para o titular
crypto-cash-inCriptoCarteira recebe um depósito onchainCarteira recebeu 1000 USDT na Polygon
otc.trade.settledOTCUm trade de OTC chega à liquidação finalBUY de 17.912 USDT liquidado, cripto entregue
otc.trade.failedOTCUm trade de OTC termina em falhaSELL 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):

HeaderDescriçãoExemplo
X-Webhook-EventTipo do evento (um dos tipos acima)pix-payment-in
X-Webhook-Event-IdId único da entrega do evento (UUID) — use para idempotência550e8400-e29b-41d4-a716-446655440000
X-Webhook-TimestampMomento em que o evento foi despachado (ISO 8601, UTC)2026-03-15T10:30:45.123Z
X-Webhook-SignatureHMAC-SHA256 em hex do body bruto da requisição, assinado com o seu webhook secret9f2c4a...
Content-TypeSempre 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:

json
{
  "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) ​

json
{
  "entity": "onboarding-create",
  "status": "CONFIRMED",
  "body": {
    "proposalId": "550e8400-e29b-41d4-a716-446655440001",
    "documentNumber": "48059890093",
    "account": {
      "account": "30054029183",
      "branch": "0001",
      "ispb": "38404082"
    }
  }
}
Status do onboardingDescrição
PROCESSINGVerificação de KYC em andamento
CONFIRMEDConta criada com sucesso
ERRORFalha 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.

json
{
  "onboardingId": "550e8400-e29b-41d4-a716-446655440001",
  "accountId": "30054029183",
  "documentNumber": "48059890093",
  "status": "PROCESSING",
  "biometricUrl": "https://kyc.provider.com/biometrics/abc123"
}
CampoTipoDescrição
onboardingIdstringId 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.
accountIdstring | undefinedId da conta bancária, quando já atribuída
documentNumberstring | undefinedDocumento do titular (CPF/CNPJ) — a chave estável para casar o onboarding com o titular da conta
statusstringSempre PROCESSING — o onboarding está aguardando a etapa de biometria
biometricUrlstringURL 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.

json
{
  "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
    }
  ]
}
CampoTipoDescrição
onboardingIdstringId do onboarding — trate como opaco; prefira o documentNumber para correlacionar
accountIdstring | undefinedId da conta bancária, quando já atribuída
documentNumberstring | undefinedDocumento do titular (CPF/CNPJ) — a chave estável para casar o onboarding com o titular da conta
statusstringSempre ERROR — igual ao status retornado pelo GET /checkOnboarding
messagesarrayMotivos 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.

json
{
  "address": "0x1122334455667788990011223344556677889900",
  "amount": "1000.00",
  "asset": "USDT",
  "txId": "0xdeadbeef...",
  "chain": "polygon-mainnet",
  "network": "EVM",
  "walletId": "a1b2c3d4-0000-0000-0000-000000000000",
  "type": "token"
}
CampoTipoDescrição
addressstringEndereço da carteira que recebeu
amountstringValor depositado (string decimal, precisão completa)
assetstringSímbolo do ativo (ex.: USDT, MATIC)
txIdstringHash da transação onchain — use para idempotência
chainstringId da chain no provedor (ex.: polygon-mainnet)
networkstringFamília da chain (EVM | BTC | TRON | SOLANA | XRPL)
walletIdstringIdentificador da carteira no registro
typestringnative | 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). O txHash traz o hash onchain.
  • otc.trade.failed — o trade terminou em falha. Nenhuma movimentação adicional ocorre; txHash vem null. Num BUY pago por QR (paymentMethod: PIX_QRCODE), a falha também cobre PIX_IN_EXPIRED (QR não pago no prazo) e PIX_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):

json
{
  "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.

CampoTipoDescrição
idstringId do trade de OTC — igual ao id retornado pelo POST /v1/otc/trades. Use para idempotência.
sidestringBUY (onramp) | SELL (offramp)
assetstringAtivo negociado (ex.: USDT)
quoteCurrencystringMoeda fiat do trade (ex.: BRL)
volumenumberQuantidade de cripto negociada
unitPricenumberPreço final aplicado por unidade, em quoteCurrency
totalAmountnumberValor total em fiat
walletAddressstringCarteira de destino (BUY) / carteira custodial de origem (SELL)
walletNetworkstringTRON | EVM | POLYGON | ETHEREUM
statusstringSETTLED | FAILED
txHashstring | nullHash onchain quando liquidado; null em caso de falha
createdAtISO 8601Quando o trade foi criado
executedAtISO 8601 | nullQuando 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

bash
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:

CampoTipoObrigatórioDescrição
urlstringSimURL do endpoint HTTPS para onde a Axia envia os eventos (HTTP é rejeitado)
eventsarraySimLista não-vazia de tipos de evento a assinar (da tabela acima)
secretstringSimSegredo compartilhado para validação da assinatura HMAC (mín. 32 caracteres). Write-only.

Response — 201 Created:

json
{
  "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

bash
curl https://baas-gtw.axiadigitalsolutions.com/v1/webhooks \
  -H "Authorization: Bearer $TOKEN"

Response — 200 OK:

json
{
  "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).

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

bash
curl -X DELETE https://baas-gtw.axiadigitalsolutions.com/v1/webhooks/550e8400-e29b-41d4-a716-446655440000 \
  -H "Authorization: Bearer $TOKEN"

Response — 200 OK:

json
{
  "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 ​

typescript
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 ​

typescript
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) ​

python
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'}), 500

Polí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 ​

TentativaEspera após a falha anterior
1ªImediata
2ª+1 segundo
3ª+2 segundos
4ª+4 segundos
Após a 4ª falhaEntrega 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:

  1. Responder rápido (< 30 segundos) — valide a assinatura, confirme o recebimento e depois processe em background
  2. Retornar 2xx imediatamente — não espere gravações no banco nem chamadas a APIs de terceiros
  3. Lidar bem com timeouts — se o processamento levar > 30s, o seu endpoint pode ser chamado de novo
  4. Implementar idempotência — use o header X-Webhook-Event-Id como chave única (veja a próxima seção)

Exemplo: confirmação rápida ​

typescript
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 ​

typescript
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) ​

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 ​

typescript
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 ​

bash
# 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 Axia

Teste unitário (Jest) ​

typescript
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-Signature em toda requisição
  • [ ] Use crypto.timingSafeEqual() para prevenir timing attacks
  • [ ] Guarde o WEBHOOK_SECRET com segurança (variável de ambiente, nunca no código)
  • [ ] Implemente idempotência com o X-Webhook-Event-Id para 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 novo secret)
  • [ ] Aplique rate-limit no processamento dos webhooks para prevenir DoS

Tratamento de erros ​

Problemas comuns ​

ProblemaCausaSolução
401 UnauthorizedAssinatura inválidaConfira se o secret confere e calcule o HMAC sobre os bytes do body bruto (sem prefixo sha256=)
502 Bad GatewayTimeout do endpoint (> 30s)Mova o processamento para um job em background e retorne 202 mais rápido
Webhook nunca entregueURL inválida ou falha de DNSTeste o endpoint com curl, verifique firewall/NAT
Eventos duplicadosRetry de rede ou ACK atrasadoImplemente idempotência com o X-Webhook-Event-Id
Eventos faltandoWebhook não cadastrado ou evento não assinadoVerifique 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

Documentação relacionada ​