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
eventIdfor deduplication - Dead Letter Queue (DLQ) — failed webhooks can be replayed manually
Event Types — All 11 Supported
| Event | Category | Trigger | Example |
|---|---|---|---|
pix-payment-in | PIX Payments | User receives PIX | João receives R$ 100 |
pix-payment-out | PIX Payments | User sends PIX | Maria sends R$ 50 |
pix-reversal-in | PIX Reversals | Incoming PIX reversed | Received R$ 100 reversed |
pix-reversal-out | PIX Reversals | Outgoing PIX reversed | Your sent R$ 50 reversed |
spb-transfer-in | TED Transfers | User receives TED | Deposit R$ 1,000 via TED |
spb-transfer-out | TED Transfers | User sends TED | You sent R$ 500 TED |
onboarding-create | Account Status | Account created / KYC approved | KYC approved, account created |
onboarding-backgroundcheck | Account Status | Background check completed | Verification of records OK |
onboarding-documentscopy | Account Status | Documents processed | KYC docs processed |
onboarding-file | Account Status | File ready for review | KYC file ready |
onboarding-proposal | Account Status | Proposal status updated | Proposal status changed |
crypto-cash-in | Crypto | Wallet receives an onchain deposit | Wallet received 1000 USDT on Polygon |
Webhook Payload Format
All webhooks follow this unified structure:
{
"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
| Field | Type | Description |
|---|---|---|
eventId | string | Unique identifier for this event (UUID) — use for idempotency |
eventType | string | Event category (one of the 11 types above) |
timestamp | ISO 8601 | When the event occurred (UTC) |
accountId | string | GlobalSCM account ID (ISPB code) |
tenantId | string | Your Axia tenant identifier |
data | object | Event-specific payload (see sections below) |
Payment Events (pix-payment-in, pix-payment-out, spb-transfer-in, spb-transfer-out)
{
"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)
{
"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.
{
"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"
}
}| Field | Type | Description |
|---|---|---|
address | string | Receiving wallet address |
amount | string | Deposited amount (decimal string, full precision) |
asset | string | Asset symbol (e.g. USDT, MATIC) |
txId | string | Onchain transaction hash — use for idempotency |
chain | string | Provider chain id (e.g. polygon-mainnet) |
network | string | Chain family (EVM | BTC | TRON | SOLANA | XRPL) |
walletId | string | Wallet identifier in the registry |
type | string | native | token |
Register a Webhook
Create Webhook Endpoint
POST /v1/webhooks
Register your webhook URL to receive event notifications.
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
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | HTTPS endpoint URL where Axia sends events |
events | array | Yes | List of event types to subscribe to (max 11) |
secret | string | Yes | Shared secret for HMAC signature validation (min 32 chars) |
Response
{
"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.
curl -X GET https://baas-gtw.axiadigitalsolutions.com/v1/webhooks \
-H "Authorization: Bearer $TOKEN"Response:
{
"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.
curl -X DELETE https://baas-gtw.axiadigitalsolutions.com/v1/webhooks/whk_550e8400-e29b-41d4-a716-446655440000 \
-H "Authorization: Bearer $TOKEN"Response:
{
"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
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
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
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'}), 500Retry 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
| Attempt | Delay | Cumulative Time |
|---|---|---|
| 1st | Immediate | 0s |
| 2nd | +1 minute | 1m |
| 3rd | +5 minutes | 6m |
| 4th | +30 minutes | 36m |
| 5th | +2 hours | 2h 36m |
| Failure | → DLQ | — |
Best Practices for Webhooks
Your endpoint should:
- Respond quickly (< 30 seconds) — validate signature, acknowledge receipt, then process in background
- Return 2xx immediately — don't wait for database writes or third-party API calls
- Handle timeouts gracefully — if processing takes > 30s, your endpoint may be called again
- Implement idempotency — use
eventIdas a unique key (see next section)
Example: Fast Acknowledgment
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
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)
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.
curl -X POST https://baas-gtw.axiadigitalsolutions.com/v1/webhooks/replay/evt_550e8400-e29b-41d4-a716-446655440000 \
-H "Authorization: Bearer $TOKEN"Response:
{
"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.
curl -X GET https://baas-gtw.axiadigitalsolutions.com/v1/webhooks/dlq \
-H "Authorization: Bearer $TOKEN"Response:
{
"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
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
# 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 AxiaUnit Testing (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);
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-Signatureheader on every request - [ ] Use
crypto.timingSafeEqual()to prevent timing attacks - [ ] Store
WEBHOOK_SECRETsecurely (environment variable, not in code) - [ ] Implement idempotency with
eventIdto 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
| Issue | Cause | Solution |
|---|---|---|
401 Unauthorized | Invalid signature | Verify secret matches, check raw request body encoding |
502 Bad Gateway | Endpoint timeout (> 30s) | Move processing to background job, return 202 faster |
Webhook never delivered | Invalid URL or DNS failure | Test endpoint with curl, check firewall/NAT |
Duplicate events | Network retry or late ACK | Implement idempotency with eventId |
Missing events | Webhook not registered | Verify 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