This documentation covers how to set up and validate Bold Bank webhooks. Webhooks allow you to receive real-time notifications about events in your Bold Bank account.

Webhooks are automated messages sent from Bold Bank to your application when specific events occur.


Webhook Overview

How to set up your webhook URL

Log in to your Bold Bank account, navigate to settings, and provide the URL endpoint where webhook events should be delivered.

Webhook payload structure

Each webhook payload contains these fields:

  • event: The event type (for example, bankTransfer.success).
  • eventData: The event payload body.
  • signature: HMAC-SHA256 signature used for verification.

Common fields inside eventData include:

  • sessionId
  • productType
  • amount
  • netAmount
  • fee
  • commission
  • transactionStatus (SUCCESS, FAILED, or PROCESSING)
  • transactionReference
  • customerReference
  • comment
  • createdAt
  • transactionAccount
  • transactionType (CREDIT or DEBIT)

Event-specific objects such as sender, beneficiary, details, or metadata can also appear.

{
  "event": "fundAccount.success",
  "eventData": {
    "sessionId": "103000250304182805630178659659",
    "productType": "Fund Account",
    "amount": "8888.00",
    "netAmount": "8888.00",
    "fee": "0.00",
    "commission": "20.00",
    "transactionStatus": "SUCCESS",
    "transactionReference": "663A33CA453D3B109286",
    "customerReference": null,
    "comment": "Sandbox payin simulation",
    "createdAt": "2025-03-04T18:28:06.000000Z",
    "transactionAccount": "8160000000",
    "transactionType": "CREDIT",
    "sender": {
      "accountName": "Yusuf Yusuf Agboola",
      "accountNumber": "9011724470",
      "bankName": "SHEPHERD TRUST MICROFINANCE BANK",
      "bankCode": "090401",
      "stampDuty": "0.00"
    }
  },
  "signature": "75c26b7e148442184761da0183c400984382c9b61383d24ce2161137da122658"
}

Always inspect eventData.transactionStatus to determine the real state of a transaction.


Partner integration events (Outlets)

Partners using Outlets receive three additional events in addition to the standard ones.

EventWhen it firesPurpose
outlet.createdAfter POST /outlets succeedsConfirms outlet and wallets are usable.
walletSweptIntra.successfulAfter an auto-sweep moves funds from an outlet collection walletReconciliation event with sweepReference.
internalTransfer.successfulAfter POST /transfer/internal settlesConfirms internal wallet movement.

Outlets also receive fundAccount.success and bankTransfer.success / bankTransfer.failed with the same payload shape documented above.


Signature Validation

To verify authenticity, compute HMAC-SHA256 and compare to the payload signature.

Your x-api-key is also the webhook signing secret. Keep it server-side only, never expose it in client applications, and rotate it immediately if leaked.

Validation Steps

  1. Extract only the eventData object.
  2. Serialize eventData as minified JSON.
  3. Compute HMAC-SHA256 using your x-api-key.
  4. Compare the hex digest with signature using a constant-time comparison.
function validateSignature(array $payload, string $apiKey): bool {
    $eventDataJson = json_encode($payload['eventData'], JSON_UNESCAPED_SLASHES);
    $calculated = hash_hmac('sha256', $eventDataJson, $apiKey);
    return hash_equals($calculated, $payload['signature']);
}

Delivery, retries and idempotency

Every webhook is an event with a stable id. The body carries it alongside the fields you already know:

{
  "event": "fundAccount.success",
  "eventData": { "...": "unchanged — see the examples above" },
  "signature": "<legacy HMAC over eventData — unchanged>",
  "eventId": "60337DB7AAF1C195D9366F4E04",
  "createdAt": "2026-09-15T13:15:58.524Z",
  "attempt": 1
}

and the request carries these headers:

HeaderValue
X-Kele-Event-IdThe event id — the same on every attempt of the same event
X-Kele-Event-TypeSame as event in the body
X-Kele-Attempt1 for the first delivery, then 2, 3, …
X-Kele-TimestampUnix seconds when this attempt was sent
X-Kele-Signaturet=<timestamp>,v1=<hex HMAC-SHA256> — see Signature v1 below
User-AgentKele-Webhooks/2

Delivery is at-least-once and unordered. Answer 2xx within 10 seconds once you have persisted the event (do your processing afterwards). Anything else — a non-2xx, a timeout, a connection error, a redirect — is retried on this schedule:

AttemptWaits after the previous one
230 s
32 min
410 min
530 min
61 h
72 h
84 h
98 h
1024 h

(± 20 % jitter; a Retry-After header on a 429/503 is honoured up to 1 hour.) After the tenth failure the event is parked as failed and shown in your dashboard, where you can retry it yourself. If your endpoint fails 20 times in a row, deliveries to it are paused for 60 seconds (doubling to a maximum of 15 minutes while it keeps failing); nothing is dropped — events queue and resume automatically, and the dashboard shows the pause.

Because retries and pauses exist, you can receive the same eventId more than once (for example if your server crashed after we sent the request but before we saw your reply). Deduplicate on eventId: keep the ids you have processed and answer 2xx to a repeat without processing it again. Do not rely on order — a retried event can arrive after a newer one; the transaction API (GET /transaction/{transactionReference}) is always the source of truth for current state.

Signature v1 (X-Kele-Signature)

The v1 signature is computed over the exact bytes of the request body, prefixed with the send timestamp, so it cannot be affected by how your JSON library re-serialises objects — the problem the legacy signature field has. Verify it like this:

  1. Read the raw request body as bytes (before any JSON parsing).
  2. Split the header on , → t=<ts> and v1=<hex>.
  3. Compute HMAC-SHA256(secret, "<ts>." + rawBody) and hex-encode it.
  4. Compare with <hex> in constant time.
  5. Reject if |now − ts| > 300 s (replay protection).

The secret is your webhook secret from Settings → Webhooks & IPs (or, until you have generated one, your x-api-key — the same key the legacy field uses).

$raw    = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_KELE_SIGNATURE'] ?? '';
[$t, $v1] = array_map(fn ($p) => explode('=', $p, 2)[1], explode(',', $header));
$expected = hash_hmac('sha256', $t.'.'.$raw, $secret);
if (!hash_equals($expected, $v1) || abs(time() - (int) $t) > 300) {
  http_response_code(401); exit;
}

The legacy body signature keeps working unchanged and will be kept for at least 12 months after this notice; new integrations should verify X-Kele-Signature.

Your event log, retries and test events

Everything we tried to deliver to you is visible in the dashboard under Settings → Webhook Events (API businesses only): each event’s status, every attempt with the status code and response your endpoint returned, and the payload. From there you can retry an event and send a test event to your URL. The same is available over the API with your usual credentials:

MethodPathPurpose
GET/webhooks/events/summary?hours=24Counts by status, success rate, endpoint health
GET/webhooks/events?status=failed&sourceRef=<transactionReference>&limit=25&before=<createdAt>List events, newest first (before pages)
GET/webhooks/events/{eventId}One event with its attempts and payload
POST/webhooks/events/{eventId}/retryDeliver it again now (same eventId)
POST/webhooks/testSend a signed webhook.test event to your URL

A retry re-sends the same eventId, so a correctly deduplicating handler will ignore it if it already has the event — use it when your endpoint was down, not to re-trigger processing.

Best Practices

  • Acknowledge fast with HTTP 2xx after persisting the payload. Any non-2xx response, a connection error, or no answer within 10 seconds counts as a failure and is retried on the schedule above.
  • Make handlers idempotent — key on eventId (or transactionReference / sweepReference), because retries can happen.
  • Always validate the signature before processing.
  • Process asynchronously to keep webhook endpoints responsive.