Skip to content
Tranzaq

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

EventWhen
payout.batch.approvedA batch was approved and the money reserved.
payout.batch.rejectedA batch was rejected.
payout.batch.cancelledA batch was cancelled.
payout.batch.completedEvery payment in a batch has a final result.
payout.item.succeededOne payment was paid.
payout.item.failedOne payment failed.
funding.creditedA deposit was credited to your balance.
webhook.testYou 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

HeaderValue
X-Tranzaq-EventThe event type.
X-Tranzaq-DeliveryA unique id for this delivery. Use it to ignore repeats.
X-Tranzaq-TimestampUnix time in seconds, fresh on every attempt.
X-Tranzaq-SignatureHex 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);
}

Answering 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-Delivery or the event id to ignore repeats, and read the current state from the API when order matters.