Webhooks
Webhooks
Signed events for every result, with retries and a delivery log.
Instead of asking us again and again, give us an address and we call you when something happens. Add addresses in Developers. Each address gets a signing secret, shown once.
Events
| Event | When |
|---|---|
| payout.batch.approved | A batch was approved and the money reserved. |
| payout.batch.rejected | A batch was rejected. |
| payout.batch.cancelled | A batch was cancelled. |
| payout.batch.completed | Every payment in a batch has a final result. |
| payout.item.succeeded | One payment was paid. |
| payout.item.failed | One payment failed. |
| funding.credited | A deposit was credited to your balance. |
| webhook.test | You pressed Send test event. |
What we send
A POST with a JSON body: an id for the event, its type, when it was created, and data.
{
"id": "9a4f5b0e-2d5c-4c6e-8a3b-0f1e2d3c4b5a",
"type": "payout.item.failed",
"createdAt": "2026-10-18T10:31:07+00:00",
"data": {
"batchId": "3f9c1c9e-6b1a-4d6f-9a77-0f6f3f3c2a10",
"batchReference": "PB-20261018-0007",
"itemId": "b3c5f0a2-3d1e-4a71-8f0e-1c2d3e4f5a6b",
"reference": "SAL-1",
"status": "Failed",
"amount": 1850.00,
"feeAmount": 9.25,
"currency": "GHS",
"failureCode": "INVALID_ACCOUNT",
"failureReason": "The account is closed"
}
}Batch events carry batchId, reference, name, status, totalAmount, totalFees, currency and itemCount. funding.credited carries fundingRequestId, reference, amount and currency.
Headers
| Header | Value |
|---|---|
| X-Tranzaq-Event | The event type. |
| X-Tranzaq-Delivery | A unique id for this delivery. Use it to ignore repeats. |
| X-Tranzaq-Timestamp | Unix time in seconds, fresh on every attempt. |
| X-Tranzaq-Signature | Hex HMAC-SHA256 of timestamp.body, made with your signing secret. |
Verify the signature
Always check the signature before you trust a call. Build the string timestamp + "." + rawBody, sign it with your secret using HMAC-SHA256, and compare it with the header in constant time. Use the raw body exactly as received, before any JSON parsing. We also suggest rejecting a timestamp more than five minutes old.
import crypto from "node:crypto";
export function isValid(rawBody, headers, secret) {
const timestamp = headers["x-tranzaq-timestamp"];
const signature = headers["x-tranzaq-signature"];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}using System.Security.Cryptography;
using System.Text;
static bool IsValid(string rawBody, string timestamp, string signature, string secret)
{
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(timestamp)) > 300) return false;
var expected = Convert.ToHexString(HMACSHA256.HashData(
Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}"))).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature.ToLowerInvariant()));
}import hashlib, hmac, time
def is_valid(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
if abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)# Reproduce a signature by hand while debugging
printf '%s' "$TIMESTAMP.$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hexAnswering and retrying
- Answer with any 2xx within 10 seconds to say you got it. Do slow work afterwards.
- Any other answer, or no answer, is retried: up to 8 attempts, waiting longer each time (it starts at about 10 seconds, doubles, and is capped at an hour).
- Every attempt is in the delivery log in Developers, with your server's answer, and you can resend one by hand.
- A paused address receives nothing; events wait and are sent when you switch it back on.
- Events can arrive more than once and out of order. Use
X-Tranzaq-Deliveryor the eventidto ignore repeats, and read the current state from the API when order matters.