Webhooks
Receive real-time notifications when events occur — transactions completing, accounts opening, loans being approved, and more.
Setup
Configure webhooks in two ways:
- Dashboard — Log in to the dashboard, navigate to Settings → Webhooks, and add your endpoint URL.
- API — Register a webhook subscription programmatically:
{
"name": "My Webhook",
"url": "https://example.com/webhooks/cba",
"secret": "whsec_your_secret_here",
"events": ["transaction.completed", "account.opened", "transfer.failed"],
"is_active": true
}
The secret is used to compute HMAC signatures. Store it securely.
Payload Format
Every webhook delivery is an HTTP POST with a JSON body:
{
"id": "evt-a1b2c3d4",
"type": "transaction.completed",
"tenant_id": "tenant-uuid",
"data": {
"transaction_id": "txn-uuid",
"transaction_ref": "TRN20260228001234",
"amount": 100000.00,
"currency": "NGN",
"status": "COMPLETED"
},
"created_at": "2026-02-28T10:00:00Z"
}
Headers
| Header | Description |
|---|---|
Content-Type | application/json |
X-Webhook-Signature | t=<unix_timestamp>,v1=<hmac_hex> |
X-Webhook-Timestamp | Unix timestamp of the signature |
X-Webhook-ID | Unique delivery ID for idempotency |
Signature Verification
The signature uses HMAC-SHA256. The signed payload is {timestamp}.{raw_body}.
- Node.js
- Python
- Go
const crypto = require("crypto");
function verifyWebhookSignature(body, signatureHeader, secret) {
const [tPart, vPart] = signatureHeader.split(",");
const timestamp = tPart.split("=")[1];
const signature = vPart.split("=")[1];
// Check timestamp tolerance (5 minutes)
if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
throw new Error("Timestamp outside tolerance");
}
const signedPayload = `${timestamp}.${body}`;
const expected = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// Express middleware
app.post("/webhooks/cba", (req, res) => {
const sig = req.headers["x-webhook-signature"];
if (!verifyWebhookSignature(JSON.stringify(req.body), sig, WEBHOOK_SECRET)) {
return res.status(401).json({ error: "Invalid signature" });
}
handleEvent(req.body);
res.status(200).json({ received: true });
});
import hmac
import hashlib
import time
def verify_webhook_signature(body: bytes, signature_header: str, secret: str) -> bool:
parts = signature_header.split(",")
timestamp = parts[0].split("=")[1]
signature = parts[1].split("=")[1]
# Check timestamp tolerance (5 minutes)
if abs(time.time() - int(timestamp)) > 300:
raise ValueError("Timestamp outside tolerance")
signed_payload = f"{timestamp}.{body.decode()}"
expected = hmac.new(
secret.encode(), signed_payload.encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"strconv"
"strings"
"time"
)
func verifyWebhookSignature(body []byte, sigHeader, secret string) (bool, error) {
parts := strings.SplitN(sigHeader, ",", 2)
timestamp := strings.TrimPrefix(parts[0], "t=")
signature := strings.TrimPrefix(parts[1], "v1=")
ts, _ := strconv.ParseInt(timestamp, 10, 64)
if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false, fmt.Errorf("timestamp outside tolerance")
}
signedPayload := fmt.Sprintf("%s.%s", timestamp, string(body))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signedPayload))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expected)), nil
}
warning
Always use constant-time comparison to prevent timing attacks. Validate the timestamp to reject replayed webhooks.
Event Types
Account Events
| Event | Description |
|---|---|
account.opened | New account created |
account.closed | Account closed |
account.frozen | Account frozen |
account.unfrozen | Account unfrozen |
Transaction Events
| Event | Description |
|---|---|
transaction.created | Transaction initiated |
transaction.completed | Transaction successful |
transaction.failed | Transaction failed |
transaction.reversed | Transaction reversed |
Transfer Events
| Event | Description |
|---|---|
transfer.initiated | Transfer started |
transfer.completed | Transfer delivered |
transfer.failed | Transfer failed |
Customer Events
| Event | Description |
|---|---|
customer.created | New customer registered |
customer.updated | Customer profile updated |
customer.kyc.approved | KYC verification approved |
customer.kyc.rejected | KYC verification rejected |
Loan Events
| Event | Description |
|---|---|
loan.applied | Loan application submitted |
loan.approved | Loan approved |
loan.disbursed | Loan funds disbursed |
loan.repaid | Loan fully repaid |
loan.defaulted | Loan in default |
Security Events
| Event | Description |
|---|---|
security.login.success | Successful login |
security.login.failed | Failed login attempt |
security.mfa.enabled | MFA enabled on account |
security.password.reset | Password reset completed |
Retry Behavior
Failed deliveries (non-2xx response) are retried with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | 1 second |
| 2 | 2 seconds |
| 3 | 4 seconds |
| 4 | 1 minute |
| 5 | 5 minutes |
After 5 failed attempts the delivery is marked as failed. Timeout per delivery is 30 seconds.
Best Practices
- Return 200 quickly — Acknowledge receipt, then process asynchronously.
- Handle duplicates — Use
X-Webhook-IDfor idempotency. - Verify signatures — Always validate before processing.
- Validate timestamps — Reject events older than 5 minutes.
- Use HTTPS — Webhook URLs must be served over TLS.
Next Steps
- Server Integration — Backend setup and error handling.
- Transactions — Transaction events and status tracking.