Webhooks
Webhooks allow Tulu Switch to push real-time event notifications to your server as things happen — a deposit confirmation, a failed transfer, a subscription charge. Instead of polling the API, your application receives an HTTP POST to a URL you control.
You configure one webhook URL per environment (TEST and LIVE are separate). Each environment also gets its own signing secret for verifying that payloads are genuine.
Registering a Webhook
Webhook configuration is done entirely from your dashboard — there is no API endpoint for this. To register a webhook URL:
- Log in to your Tulu Switch dashboard.
- Navigate to Account > Webhooks in the sidebar.
- Enter your HTTPS endpoint URL and save.
- Copy the generated signing secret immediately — it will not be shown again.
TEST and LIVE webhook URLs are configured separately. Switch between environments in the dashboard to configure each one.
Webhook Secret
The webhook secret is a whsec_-prefixed 64-character hex string. It is used to compute the HMAC-SHA256 signature sent with every event. Store it in an environment variable and never expose it in client-side code.
You can rotate the secret at any time from Account > Webhooks in the dashboard. The old secret becomes invalid immediately on rotation.
Delivery Headers
Every webhook POST to your URL includes these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Tulu-Switch-Signature | HMAC-SHA256 hex of the raw JSON body |
X-Tulu-Switch-Event | Event name (e.g. deposit.success) |
X-Tulu-Switch-Timestamp | ISO 8601 timestamp of delivery |
Signature Verification
Compute HMAC-SHA256(secret, rawBody) and compare it to the X-Tulu-Switch-Signature header using a timing-safe comparison. Always verify before processing an event.
import crypto from 'crypto';
function verifyWebhook(rawBody: string, signature: string, secret: string): boolean {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
// Express example
app.post('/webhooks/tulu', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-tulu-switch-signature'] as string;
if (!verifyWebhook(req.body.toString(), sig, process.env.WEBHOOK_SECRET!)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString());
// process event.event ...
res.status(200).json({ received: true });
});Events Reference
deposit.successA merchant deposit was confirmed by the provider.
deposit.failedA merchant deposit failed or was rejected by the provider.
transfer.successAn outbound bank transfer (payout) was confirmed.
transfer.failedAn outbound bank transfer failed or was reversed.
bank_account.deposit.successFunds arrived at a merchant or MFB virtual NUBAN account.
checkout.paidA one-time virtual account (checkout) received payment.
customer.deposit.successA customer wallet deposit was confirmed.
customer.deposit.failedA customer wallet deposit failed.
subscription.chargedA recurring subscription cycle was charged successfully.
refund.completedA refund was confirmed and credited by the provider.
Payload Structure
All webhook payloads share the same top-level envelope. The data object varies by event type.
{
"event": "deposit.success",
"data": {
"id": "txn_xxx",
"amount": 5000,
"currency": "NGN",
"status": "COMPLETED",
"reference": "deposit_abc123",
"customerId": "cus_yyy",
"provider": "Paystack",
"channel": "WALLET"
},
"timestamp": "2025-01-01T00:00:00.000Z"
}event — the event name string
data — event-specific payload (transaction, customer, subscription, etc.)
timestamp — ISO 8601 datetime of when the event was fired
Retry Policy
If your endpoint does not return a 2xx status within 10 seconds, Tulu Switch will retry delivery:
| Attempt | Delay before retry |
|---|---|
| 1st attempt | Immediate |
| 2nd attempt | 1 second |
| 3rd attempt | 5 seconds |
| 4th attempt (final) | 30 seconds |
After 3 retries (4 total attempts), the delivery is marked as failed. Design your webhook handler to be idempotent — the same event may be delivered more than once.
Best Practices
- Always verify the
X-Tulu-Switch-Signatureheader before processing any event - Return a
200response immediately, then process the event asynchronously - Make your handler idempotent — use the transaction or event ID to detect and skip duplicates
- Keep your webhook secret in an environment variable; rotate it from the dashboard if it may have been exposed
- Only use HTTPS endpoints — plain HTTP is rejected
- Log all received events with the full payload for debugging and auditing