---
title: Webhooks
description: 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](https://console.example.com) under **Webhooks**.

- Give a public `https` URL and pick the event types you want. A URL that is not `https`, 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](/guides/settlements). |
| `transfer.status_changed`   | A wallet transfer you sent moves to a new status. Only the sender gets it. See [Wallet transfers](/guides/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:

```json
{
  "id": "evt_3f2a9c0d8e7b4a1f9c2d6e5b4a3f2e1d",
  "type": "collection.status_changed",
  "createdAt": "2026-09-29T10:00:00Z",
  "data": { }
}
```

- `id` is the event ID, the same as the `Webhook-Id` header.
- `type` is one of the event types above.
- `createdAt` is when the change happened.
- `data` is 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 with `GET /v1/collections/{reference}`, `GET /v1/payouts/{reference}`, `GET /v1/utility-purchases/{reference}` or `GET /v1/transfers/{reference}`. A settlement run's `data` has no `id`: list runs with `GET /v1/settlements` and match on `reference`.

Each event's full payload schema is also in the API reference, for example [payout.status_changed](/reference/webhooks/webhook-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:

```text
<Webhook-Id>.<Webhook-Timestamp>.<raw request body>
```

The key is your endpoint's signing secret, used exactly as issued.

1. Read the raw body bytes. Do not parse and re-encode the JSON first. Whitespace and key order are part of what was signed.
2. Compute the HMAC-SHA256 and base64 it.
3. Compare with the value after `v1,` in constant time.
4. Reject the delivery if `Webhook-Timestamp` is more than 5 minutes from now.

**Node.js**

```js
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);
}
```

**Python**

```python
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**

```php
<?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]);
}
```

**Go**

```go
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 `id` and skip ones you have seen.
- A non-`2xx` answer, 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_changed` event can land before the `accepted` one, or an older status can land after a newer one. Compare statuses, or read the current state with `GET`, rather than trusting arrival order.

If you were down for a long time, catch up with `GET /v1/events`. See [Transactions and events](/guides/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.
