Skip to content

Webhooks

Webhooks provide real-time push notifications for banking events. Instead of polling for status updates, Axia delivers events directly to your application whenever important banking activities occur.

Overview

Webhooks = Real-Time Banking Notifications

GlobalSCM backend supports 11 distinct event types covering PIX payments, TED transfers, and onboarding status changes. Axia abstracts GlobalSCM's complexity and adds:

  • HMAC SHA256 signature validation — prevent webhook spoofing
  • Automatic retry policy — 5 attempts with exponential backoff (1m → 5m → 30m → 2h → 24h)
  • Idempotency guarantee — each event has a unique eventId for deduplication
  • Dead Letter Queue (DLQ) — failed webhooks can be replayed manually

Event Types — All 11 Supported

EventCategoryTriggerExample
pix-payment-inPIX PaymentsUser receives PIXJoão receives R$ 100
pix-payment-outPIX PaymentsUser sends PIXMaria sends R$ 50
pix-reversal-inPIX ReversalsIncoming PIX reversedReceived R$ 100 reversed
pix-reversal-outPIX ReversalsOutgoing PIX reversedYour sent R$ 50 reversed
spb-transfer-inTED TransfersUser receives TEDDeposit R$ 1,000 via TED
spb-transfer-outTED TransfersUser sends TEDYou sent R$ 500 TED
onboarding-createAccount StatusAccount created / KYC approvedKYC approved, account created
onboarding-backgroundcheckAccount StatusBackground check completedVerification of records OK
onboarding-documentscopyAccount StatusDocuments processedKYC docs processed
onboarding-fileAccount StatusFile ready for reviewKYC file ready
onboarding-proposalAccount StatusProposal status updatedProposal status changed
crypto-cash-inCryptoWallet receives an onchain depositWallet received 1000 USDT on Polygon

Webhook Payload Format

All webhooks follow this unified structure:

json
{
  "eventId": "evt_550e8400-e29b-41d4-a716-446655440000",
  "eventType": "pix-payment-in",
  "timestamp": "2024-03-15T10:30:45.123Z",
  "accountId": "30054029183",
  "tenantId": "tenant_xyz",
  "data": {
    "transactionId": "E384040820262603101234567890",
    "endToEndId": "E384040820262603101234567890",
    "amount": 150.00,
    "currency": "BRL",
    "creditParty": {
      "name": "João da Silva",
      "taxId": "48059890093",
      "accountNumber": "123456",
      "bankCode": "001"
    },
    "debitParty": {
      "name": "Maria Souza",
      "taxId": "12345678901",
      "accountNumber": "789012",
      "bankCode": "001"
    }
  }
}

Payload Fields

FieldTypeDescription
eventIdstringUnique identifier for this event (UUID) — use for idempotency
eventTypestringEvent category (one of the 11 types above)
timestampISO 8601When the event occurred (UTC)
accountIdstringGlobalSCM account ID (ISPB code)
tenantIdstringYour Axia tenant identifier
dataobjectEvent-specific payload (see sections below)

Payment Events (pix-payment-in, pix-payment-out, spb-transfer-in, spb-transfer-out)

json
{
  "eventId": "evt_550e8400-e29b-41d4-a716-446655440000",
  "eventType": "pix-payment-in",
  "timestamp": "2024-03-15T10:30:45.123Z",
  "accountId": "30054029183",
  "tenantId": "tenant_xyz",
  "data": {
    "transactionId": "E384040820262603101234567890",
    "endToEndId": "E384040820262603101234567890",
    "amount": 150.00,
    "currency": "BRL",
    "status": "COMPLETED",
    "creditParty": {
      "name": "João da Silva",
      "taxId": "48059890093",
      "accountNumber": "123456",
      "bankCode": "001"
    },
    "debitParty": {
      "name": "Maria Souza",
      "taxId": "12345678901",
      "accountNumber": "789012",
      "bankCode": "001"
    }
  }
}

Onboarding Events (onboarding-create, onboarding-backgroundcheck, onboarding-documentscopy, onboarding-file, onboarding-proposal)

json
{
  "eventId": "evt_550e8400-e29b-41d4-a716-446655440001",
  "eventType": "onboarding-create",
  "timestamp": "2024-03-15T10:30:45.123Z",
  "accountId": "30054029183",
  "tenantId": "tenant_xyz",
  "data": {
    "status": "CONFIRMED",
    "documentNumber": "48059890093",
    "accountId": "30054029183",
    "previousStatus": "PROCESSING",
    "metadata": {
      "kycApprovedAt": "2024-03-15T10:30:00Z",
      "verificationMethod": "automated"
    }
  }
}

Crypto Events (crypto-cash-in)

Sent when a tenant wallet receives an onchain deposit. accountId is the receiving wallet address. The amount is delivered as a decimal string (full onchain precision); txId is the onchain transaction hash — use it for idempotency.

json
{
  "eventId": "evt_550e8400-e29b-41d4-a716-446655440002",
  "eventType": "crypto-cash-in",
  "timestamp": "2026-06-06T12:00:00.000Z",
  "accountId": "0x1122334455667788990011223344556677889900",
  "tenantId": "tenant_xyz",
  "data": {
    "address": "0x1122334455667788990011223344556677889900",
    "amount": "1000.00",
    "asset": "USDT",
    "txId": "0xdeadbeef...",
    "chain": "polygon-mainnet",
    "network": "EVM",
    "walletId": "a1b2c3d4-0000-0000-0000-000000000000",
    "type": "token"
  }
}
FieldTypeDescription
addressstringReceiving wallet address
amountstringDeposited amount (decimal string, full precision)
assetstringAsset symbol (e.g. USDT, MATIC)
txIdstringOnchain transaction hash — use for idempotency
chainstringProvider chain id (e.g. polygon-mainnet)
networkstringChain family (EVM | BTC | TRON | SOLANA | XRPL)
walletIdstringWallet identifier in the registry
typestringnative | token

Register a Webhook

Create Webhook Endpoint

POST /v1/webhooks

Register your webhook URL to receive event notifications.

bash
curl -X POST https://baas-gtw.axiadigitalsolutions.com/v1/webhooks \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://seu-app.com/webhooks/baas",
    "events": ["pix-payment-in", "pix-payment-out", "onboarding-create"],
    "secret": "whsec_your_secret_key_here"
  }'

Request Body

FieldTypeRequiredDescription
urlstringYesHTTPS endpoint URL where Axia sends events
eventsarrayYesList of event types to subscribe to (max 11)
secretstringYesShared secret for HMAC signature validation (min 32 chars)

Response

json
{
  "webhookId": "whk_550e8400-e29b-41d4-a716-446655440000",
  "url": "https://seu-app.com/webhooks/baas",
  "events": ["pix-payment-in", "pix-payment-out", "onboarding-create"],
  "status": "active",
  "createdAt": "2024-03-15T10:00:00Z",
  "lastDeliveryAt": null
}

List & Delete Webhooks

List All Webhooks

GET /v1/webhooks

Retrieve all registered webhooks for your tenant.

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

Response:

json
{
  "data": [
    {
      "webhookId": "whk_550e8400-e29b-41d4-a716-446655440000",
      "url": "https://seu-app.com/webhooks/baas",
      "events": ["pix-payment-in", "onboarding-create"],
      "status": "active",
      "createdAt": "2024-03-15T10:00:00Z",
      "lastDeliveryAt": "2024-03-15T10:30:45.123Z"
    }
  ],
  "pagination": {
    "total": 1,
    "page": 1,
    "limit": 10
  }
}

Delete Webhook

DELETE /v1/webhooks/{webhookId}

Unregister a webhook to stop receiving events.

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

Response:

json
{
  "webhookId": "whk_550e8400-e29b-41d4-a716-446655440000",
  "status": "deleted",
  "deletedAt": "2024-03-15T10:45:00Z"
}

Signature Validation (CRITICAL)

Every webhook request includes an X-Webhook-Signature header with an HMAC-SHA256 hash of the request body signed with your webhook secret. You MUST validate this signature to prevent spoofing attacks.

Header Format

X-Webhook-Signature: sha256=<hex-encoded-hmac-sha256-hash>

Validation Algorithm

typescript
import crypto from 'crypto';

function validateSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  // Parse header: "sha256=abc123def456..."
  const [scheme, hash] = signature.split('=');
  
  if (scheme !== 'sha256') {
    console.warn(`Unknown signature scheme: ${scheme}`);
    return false;
  }
  
  // Compute expected hash
  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
  
  // Timing-safe comparison to prevent timing attacks
  try {
    return crypto.timingSafeEqual(
      Buffer.from(hash),
      Buffer.from(expected)
    );
  } catch {
    return false; // Length mismatch
  }
}

Express.js Implementation

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 event = JSON.parse(rawBody);
      // Process event (see idempotency section)
      
      res.status(200).json({ received: true });
    } catch (err) {
      console.error('Webhook processing error:', err);
      res.status(500).json({ error: 'Internal server error' });
    }
  }
);

Python (Flask) Implementation

python
import hmac
import hashlib
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:
    scheme, hash_value = signature.split('=')
    if scheme != 'sha256':
        return False
    
    expected = hmac.new(
        secret.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()
    
    return hmac.compare_digest(hash_value, 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 = request.get_json()
        # Process event
        
        return jsonify({'received': True}), 200
    except Exception as err:
        logging.error(f'Webhook error: {err}')
        return jsonify({'error': 'Internal server error'}), 500

Retry Policy

Axia automatically retries failed webhook deliveries. A delivery is considered successful if your endpoint responds with HTTP status 200–299 within 30 seconds.

Retry Schedule

AttemptDelayCumulative Time
1stImmediate0s
2nd+1 minute1m
3rd+5 minutes6m
4th+30 minutes36m
5th+2 hours2h 36m
Failure→ DLQ

Best Practices for Webhooks

Your endpoint should:

  1. Respond quickly (< 30 seconds) — validate signature, acknowledge receipt, then process in background
  2. Return 2xx immediately — don't wait for database writes or third-party API calls
  3. Handle timeouts gracefully — if processing takes > 30s, your endpoint may be called again
  4. Implement idempotency — use eventId as a unique key (see next section)

Example: Fast Acknowledgment

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 = JSON.parse(rawBody);
      
      // Respond immediately
      res.status(202).send('Accepted');
      
      // Process in background (don't await)
      processWebhookAsync(event).catch(err => {
        console.error('Background webhook processing failed:', err);
      });
    } catch (err) {
      console.error('Webhook error:', err);
      res.status(500).send('Error');
    }
  }
);

async function processWebhookAsync(event) {
  // Heavy work: database writes, notificatons, etc.
  await updateUserBalance(event.accountId, event.data.amount);
  await sendNotification(event.accountId, `Received R$ ${event.data.amount}`);
}

Idempotency in Webhooks

Network instability may cause Axia to deliver the same webhook multiple times. Each event has a unique eventId — use it as a key to prevent duplicate processing.

Idempotency Implementation

typescript
async function processWebhook(event: WebhookEvent) {
  const existingEvent = await db.webhookEvent.findUnique({
    where: { eventId: event.eventId }
  });
  
  if (existingEvent) {
    console.log(`Webhook ${event.eventId} already processed, skipping`);
    return; // Already handled
  }
  
  // Mark as processing
  await db.webhookEvent.create({
    data: {
      eventId: event.eventId,
      eventType: event.eventType,
      accountId: event.accountId,
      payload: event.data,
      processedAt: new Date()
    }
  });
  
  // Execute business logic
  switch (event.eventType) {
    case 'pix-payment-in':
      await updateBalance(
        event.accountId,
        event.data.amount,
        'credit'
      );
      break;
    
    case 'onboarding-create':
      await updateAccountStatus(
        event.accountId,
        event.data.status
      );
      break;
    
    // ... other event types
  }
}

Database Schema (Prisma)

prisma
model WebhookEvent {
  id String @id @default(cuid())
  eventId String @unique // Unique per event
  eventType String
  accountId String
  payload Json
  processedAt DateTime @default(now())
  
  @@index([eventId])
  @@index([accountId])
}

Dead Letter Queue (DLQ) — Replay Failed Webhooks

If a webhook fails all 5 retry attempts, Axia moves it to a Dead Letter Queue. You can inspect and manually replay failed deliveries.

Replay a Failed Webhook

POST /v1/webhooks/replay/{eventId}

Manually trigger redelivery of a failed webhook.

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

Response:

json
{
  "eventId": "evt_550e8400-e29b-41d4-a716-446655440000",
  "webhookId": "whk_550e8400-e29b-41d4-a716-446655440000",
  "status": "pending_retry",
  "nextRetryAt": "2024-03-15T10:31:45.123Z"
}

List DLQ Entries

GET /v1/webhooks/dlq

Inspect webhooks that failed all retries.

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

Response:

json
{
  "data": [
    {
      "eventId": "evt_550e8400-e29b-41d4-a716-446655440000",
      "eventType": "pix-payment-in",
      "webhookId": "whk_550e8400-e29b-41d4-a716-446655440000",
      "failedAt": "2024-03-15T12:36:45.123Z",
      "lastError": "HTTP 500: Internal Server Error",
      "retryCount": 5
    }
  ],
  "pagination": {
    "total": 3,
    "page": 1
  }
}

View DLQ in BackOffice

Navigate to Auditoria → Webhooks → DLQ in the Axia BackOffice to inspect and replay failed webhooks via the UI.


Complete End-to-End Example

Node.js + Express Setup

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

// ============================================================================
// Signature Validation
// ============================================================================

function validateSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const [scheme, hash] = signature.split('=');
  
  if (scheme !== 'sha256') {
    return false;
  }
  
  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
  
  try {
    return crypto.timingSafeEqual(
      Buffer.from(hash),
      Buffer.from(expected)
    );
  } catch {
    return false;
  }
}

// ============================================================================
// Webhook Handler
// ============================================================================

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');
      
      // Validate signature
      if (!sig || !validateSignature(rawBody, sig, webhookSecret)) {
        console.warn('Invalid webhook signature');
        return res.status(401).json({ error: 'Unauthorized' });
      }
      
      const event = JSON.parse(rawBody);
      
      // Acknowledge receipt immediately
      res.status(202).json({ status: 'accepted' });
      
      // Process in 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' });
    }
  }
);

// ============================================================================
// Background Processing
// ============================================================================

async function processWebhookAsync(event: any) {
  console.log(`Processing webhook: ${event.eventId} (${event.eventType})`);
  
  // Check idempotency
  const existing = await db.webhookEvent.findUnique({
    where: { eventId: event.eventId }
  });
  
  if (existing) {
    console.log(`Webhook ${event.eventId} already processed`);
    return;
  }
  
  try {
    // Record receipt
    await db.webhookEvent.create({
      data: {
        eventId: event.eventId,
        eventType: event.eventType,
        accountId: event.accountId,
        tenantId: event.tenantId,
        payload: event.data,
        processedAt: new Date(event.timestamp)
      }
    });
    
    // Handle by event type
    switch (event.eventType) {
      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-create':
      case 'onboarding-backgroundcheck':
      case 'onboarding-documentscopy':
      case 'onboarding-file':
      case 'onboarding-proposal':
        await handleOnboardingUpdate(event);
        break;
      
      default:
        console.warn(`Unknown event type: ${event.eventType}`);
    }
    
    console.log(`✓ Webhook ${event.eventId} processed successfully`);
  } catch (err) {
    console.error(`✗ Failed to process webhook ${event.eventId}:`, err);
    throw err;
  }
}

async function handleIncomingPayment(event: any) {
  const { amount, currency, creditParty, debitParty } = event.data;
  
  // Update user balance
  await db.user.update({
    where: { accountId: event.accountId },
    data: {
      balance: {
        increment: amount
      }
    }
  });
  
  // Create transaction record
  await db.transaction.create({
    data: {
      accountId: event.accountId,
      type: 'CREDIT',
      amount,
      currency,
      description: `PIX received from ${debitParty.name}`,
      externalId: event.data.transactionId,
      externalEndToEndId: event.data.endToEndId
    }
  });
  
  // Send notification
  await sendPushNotification(
    event.accountId,
    `Received ${currency} ${amount}`,
    `From ${debitParty.name}`
  );
}

async function handleOutgoingPayment(event: any) {
  const { amount, currency, creditParty, debitParty } = event.data;
  
  // Update user balance
  await db.user.update({
    where: { accountId: event.accountId },
    data: {
      balance: {
        decrement: amount
      }
    }
  });
  
  // Create transaction record
  await db.transaction.create({
    data: {
      accountId: event.accountId,
      type: 'DEBIT',
      amount,
      currency,
      description: `PIX sent to ${creditParty.name}`,
      externalId: event.data.transactionId,
      externalEndToEndId: event.data.endToEndId
    }
  });
  
  // Send notification
  await sendPushNotification(
    event.accountId,
    `Sent ${currency} ${amount}`,
    `To ${creditParty.name}`
  );
}

async function handleReversal(event: any) {
  const { amount, currency } = event.data;
  const isInbound = event.eventType === 'pix-reversal-in';
  
  // Adjust balance (reverse the original transaction)
  await db.user.update({
    where: { accountId: event.accountId },
    data: {
      balance: isInbound ? { decrement: amount } : { increment: amount }
    }
  });
  
  // Create reversal transaction
  await db.transaction.create({
    data: {
      accountId: event.accountId,
      type: isInbound ? 'DEBIT' : 'CREDIT',
      amount,
      currency,
      description: `PIX reversal (${event.eventType})`,
      externalId: event.data.transactionId
    }
  });
  
  // Notify user
  await sendPushNotification(
    event.accountId,
    `PIX reversal processed`,
    `${currency} ${amount} has been reversed`
  );
}

async function handleOnboardingUpdate(event: any) {
  const { status, documentNumber } = event.data;
  
  // Update account status
  await db.user.update({
    where: { accountId: event.accountId },
    data: {
      onboardingStatus: status,
      kycVerifiedAt: status === 'CONFIRMED' ? new Date() : null
    }
  });
  
  // Notify based on status
  if (status === 'CONFIRMED') {
    await sendPushNotification(
      event.accountId,
      'Account Approved',
      'Your account is now ready to use'
    );
  } else if (status === 'ERROR') {
    await sendPushNotification(
      event.accountId,
      'Onboarding Failed',
      'Please contact support for assistance'
    );
  }
}

async function sendPushNotification(
  accountId: string,
  title: string,
  body: string
) {
  try {
    // Integrate with your push notification service
    // e.g., Firebase Cloud Messaging, OneSignal, etc.
    console.log(`📱 Notification to ${accountId}: ${title}`);
  } catch (err) {
    console.error('Failed to send notification:', err);
  }
}

// ============================================================================
// Server
// ============================================================================

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;

Testing Webhooks

Using Webhook Testing Tools

Popular tools for testing webhooks locally:

  • Webhook.cool — Generated URL captures requests
  • ngrok — Exposes local server to internet with HTTPS
  • Beeceptor — Mock API + webhook inspection
  • Postman — Send webhook requests manually

Local Testing with ngrok

bash
# Expose local webhook endpoint to internet
ngrok http 3000

# Output: https://abcd-1234.ngrok.io

# Register webhook:
curl -X POST https://baas-gtw.axiadigitalsolutions.com/v1/webhooks \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "url": "https://abcd-1234.ngrok.io/webhooks/baas",
    "events": ["pix-payment-in"],
    "secret": "whsec_test_12345678901234567890"
  }'

# Now test webhook delivery from Axia

Unit Testing (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);
  const hash = crypto
    .createHmac('sha256', secret)
    .update(payloadString)
    .digest('hex');
  return `sha256=${hash}`;
}

describe('Webhook Endpoint', () => {
  it('should accept valid webhook with correct signature', async () => {
    const payload = {
      eventId: 'evt_test_123',
      eventType: 'pix-payment-in',
      timestamp: new Date().toISOString(),
      accountId: '30054029183',
      tenantId: 'tenant_test',
      data: {
        transactionId: 'E384040820262603101234567890',
        amount: 100.00,
        currency: 'BRL'
      }
    };
    
    const signature = signPayload(payload, webhookSecret);
    
    const response = await request(app)
      .post('/webhooks/baas')
      .set('X-Webhook-Signature', signature)
      .send(payload);
    
    expect(response.status).toBe(202);
    expect(response.body.status).toBe('accepted');
  });
  
  it('should reject webhook with invalid signature', async () => {
    const payload = {
      eventId: 'evt_test_124',
      eventType: 'pix-payment-in',
      timestamp: new Date().toISOString(),
      accountId: '30054029183',
      tenantId: 'tenant_test',
      data: { amount: 100.00 }
    };
    
    const response = await request(app)
      .post('/webhooks/baas')
      .set('X-Webhook-Signature', 'sha256=invalidsignature')
      .send(payload);
    
    expect(response.status).toBe(401);
    expect(response.body.error).toBe('Unauthorized');
  });
  
  it('should handle idempotent duplicate deliveries', async () => {
    const payload = {
      eventId: 'evt_idempotent_test',
      eventType: 'pix-payment-in',
      timestamp: new Date().toISOString(),
      accountId: '30054029183',
      tenantId: 'tenant_test',
      data: { amount: 50.00 }
    };
    
    const signature = signPayload(payload, webhookSecret);
    
    // First delivery
    const res1 = await request(app)
      .post('/webhooks/baas')
      .set('X-Webhook-Signature', signature)
      .send(payload);
    expect(res1.status).toBe(202);
    
    // Duplicate delivery (same eventId)
    const res2 = await request(app)
      .post('/webhooks/baas')
      .set('X-Webhook-Signature', signature)
      .send(payload);
    expect(res2.status).toBe(202); // Still accepted, but not reprocessed
  });
});

Security Checklist

  • [ ] Always use HTTPS for webhook URLs
  • [ ] Validate X-Webhook-Signature header on every request
  • [ ] Use crypto.timingSafeEqual() to prevent timing attacks
  • [ ] Store WEBHOOK_SECRET securely (environment variable, not in code)
  • [ ] Implement idempotency with eventId to prevent duplicate processing
  • [ ] Respond quickly (< 30s) and process heavy work asynchronously
  • [ ] Log all webhook deliveries for audit trail
  • [ ] Monitor DLQ for failed deliveries
  • [ ] Rotate webhook secret periodically
  • [ ] Rate-limit webhook processing to prevent DoS

Error Handling

Common Issues

IssueCauseSolution
401 UnauthorizedInvalid signatureVerify secret matches, check raw request body encoding
502 Bad GatewayEndpoint timeout (> 30s)Move processing to background job, return 202 faster
Webhook never deliveredInvalid URL or DNS failureTest endpoint with curl, check firewall/NAT
Duplicate eventsNetwork retry or late ACKImplement idempotency with eventId
Missing eventsWebhook not registeredVerify webhook exists in /v1/webhooks list

Support

For webhook issues:

  • Check BackOffice Auditoria → Webhooks for delivery logs
  • Inspect DLQ entries at /v1/webhooks/dlq
  • Enable verbose logging in your application
  • Test with Webhook.cool to capture raw requests