Webhooks
Get a signed call when a payment, settlement or transfer changes state.
A webhook is an HTTPS POST that iSmartPay sends to your server when a payment changes. It saves you from polling.
Register an endpoint
Register endpoints in the developer console under Webhooks.
- Give a public
httpsURL and pick the event types you want. A URL that is nothttps, or that resolves to a private address, is refused. - You can register up to 10 endpoints per business, and each URL only once.
- Edit the URL, the event types or the enabled switch at any time. The signing secret stays the same.
- The signing secret is shown once, when you create the endpoint. Store it on your server.
- Test and live have separate endpoints and separate secrets.
Event types
| Type | Sent when |
|---|---|
collection.accepted |
A collection is accepted and the payer has been asked to approve it. |
collection.status_changed |
A collection moves to a new status, such as settled, failed or expired. |
payout.accepted |
A payout is accepted and is being sent. |
payout.status_changed |
A payout moves to a new status, such as delivered, failed or refunded. |
utility_purchase.accepted |
An airtime or data purchase is accepted. |
utility_purchase.status_changed |
A purchase moves to a new status, such as delivered or refunded. |
settlement.status_changed |
A settlement run starts or finishes. See Settlements. |
transfer.status_changed |
A wallet transfer you sent moves to a new status. Only the sender gets it. See Wallet transfers. |
transfer.received |
A wallet transfer to you completed. Only the recipient gets it. |
An endpoint only receives the types it subscribes to.
Utility purchase, settlement and transfer events start once an endpoint is created or edited in the console. If your endpoints are older than these features, edit or add one.
There are no checkout or batch events. A paid hosted checkout arrives as a collection, and each payout in a batch has its own payout events.
What arrives
Each delivery carries these headers:
| Header | Value |
|---|---|
Webhook-Id |
The event ID. It stays the same on every retry. |
Webhook-Timestamp |
Unix time in seconds. It is fresh on every attempt. |
Webhook-Signature |
v1, followed by a base64 signature (see below). |
The body is JSON:
{
"id": "evt_3f2a9c0d8e7b4a1f9c2d6e5b4a3f2e1d",
"type": "collection.status_changed",
"createdAt": "2026-09-29T10:00:00Z",
"data": { }
}
idis the event ID, the same as theWebhook-Idheader.typeis one of the event types above.createdAtis when the change happened.datais the collection, payout, utility purchase, settlement run or transfer as it stood when the event was sent. Fields the event did not carry are left out, so do not assume every field is there. When you need the full record, fetch it by reference withGET /v1/collections/{reference},GET /v1/payouts/{reference},GET /v1/utility-purchases/{reference}orGET /v1/transfers/{reference}. A settlement run’sdatahas noid: list runs withGET /v1/settlementsand match onreference.
Each event’s full payload schema is also in the API reference, for example payout.status_changed.
Verify the signature
Always verify before you act on a delivery.
The signature is v1, plus the base64 of an HMAC-SHA256. The signed text is:
<Webhook-Id>.<Webhook-Timestamp>.<raw request body>
The key is your endpoint’s signing secret, used exactly as issued.
- Read the raw body bytes. Do not parse and re-encode the JSON first. Whitespace and key order are part of what was signed.
- Compute the HMAC-SHA256 and base64 it.
- Compare with the value after
v1,in constant time. - Reject the delivery if
Webhook-Timestampis more than 5 minutes from now.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyWebhook(secret, headers, rawBody) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signature = headers["webhook-signature"];
if (!id || !timestamp || !signature) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const [version, encoded] = signature.split(",");
if (version !== "v1" || !encoded) return false;
const expected = createHmac("sha256", secret)
.update(`${id}.${timestamp}.`)
.update(rawBody)
.digest();
const received = Buffer.from(encoded, "base64");
return received.length === expected.length && timingSafeEqual(received, expected);
}import base64
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_webhook(secret: str, headers: dict, raw_body: bytes) -> bool:
webhook_id = headers.get("Webhook-Id")
timestamp = headers.get("Webhook-Timestamp")
signature = headers.get("Webhook-Signature")
if not (webhook_id and timestamp and signature):
return False
try:
age = abs(time.time() - int(timestamp))
except ValueError:
return False
if age > TOLERANCE_SECONDS:
return False
version, _, encoded = signature.partition(",")
if version != "v1" or not encoded:
return False
signed = f"{webhook_id}.{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).digest()
try:
received = base64.b64decode(encoded, validate=True)
except ValueError:
return False
return hmac.compare_digest(received, expected)<?php
const TOLERANCE_SECONDS = 300;
function verifyWebhook(string $secret, array $headers, string $rawBody): bool
{
$id = $headers['Webhook-Id'] ?? '';
$timestamp = $headers['Webhook-Timestamp'] ?? '';
$signature = $headers['Webhook-Signature'] ?? '';
if ($id === '' || $timestamp === '' || $signature === '') {
return false;
}
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) {
return false;
}
$parts = explode(',', $signature, 2);
if (count($parts) !== 2 || $parts[0] !== 'v1' || $parts[1] === '') {
return false;
}
$expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$rawBody", $secret, true));
return hash_equals($expected, $parts[1]);
}package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"math"
"strconv"
"strings"
"time"
)
const toleranceSeconds = 300
func Verify(secret, id, timestamp, signature string, rawBody []byte) bool {
if id == "" || timestamp == "" || signature == "" {
return false
}
sent, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-sent)) > toleranceSeconds {
return false
}
version, encoded, found := strings.Cut(signature, ",")
if !found || version != "v1" || encoded == "" {
return false
}
received, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(id + "." + timestamp + "."))
mac.Write(rawBody)
return hmac.Equal(received, mac.Sum(nil))
}Respond with any 2xx status within 10 seconds, once you have stored the event. Do the slow work afterwards.
Retries and duplicates
- Delivery is at-least-once. The same event can arrive more than once. Store the event
idand skip ones you have seen. - A non-
2xxanswer, a timeout or a connection error is retried. There are 6 attempts over about a day, with growing gaps between them. - Redirects are not followed, and a redirect counts as a failed attempt. Register the final URL.
- Events can arrive out of order. A
status_changedevent can land before theacceptedone, or an older status can land after a newer one. Compare statuses, or read the current state withGET, rather than trusting arrival order.
If you were down for a long time, catch up with GET /v1/events. See Transactions and events.
Rotate the secret
Rotate from the console. The new secret is shown once and signs every delivery from then on. The old one keeps verifying for 24 hours, so you can accept either while you deploy the new one. The console shows when the old secret stops working.
Test your endpoint
Press Send test event on an endpoint in the console. iSmartPay sends a webhook.test event to that URL, signed the same way as a real one. A test event is only sent to an enabled endpoint. Use it to check your signature code before any real payment.
See what was delivered
Open Deliveries on an endpoint in the console. Newest first, each delivery shows its status (pending, succeeded, failed or cancelled), the event type, the number of attempts and, while it is pending, when the next attempt is due. Each attempt lists the status code your server returned, how long it took, an error class (timeout, network, refused, redirect, http_4xx or http_5xx) and the first part of your response.
To send a delivery again, press Replay on one that has succeeded or failed. The replay is the same event with the same ID, so your duplicate check will see it.