---
title: Webhooks
description: Events, signatures and retries.
group: concepts
order: 5
---

Add an endpoint under **Developers > Webhooks**. We send a `POST` with a JSON body for each event.

## Events

| Event | When |
| --- | --- |
| `transaction.successful` | A sale delivered and its fee was settled |
| `transaction.failed` | A sale failed and its hold was released |
| `transaction.pending` | A sale is waiting on the network's answer |
| `transaction.refunded` | We refunded a sale |
| `wallet.funded` | A top-up was credited |
| `wallet.low_balance` | The wallet dropped below your alert level |
| `webhook.ping` | A test you sent from the dashboard |

## The body

```json
{
  "id": "0192f4c2-8d1e-7a3b-9c4f-5e6a7b8c9d0e",
  "type": "transaction.successful",
  "mode": "live",
  "occurred_at": "2026-10-06T12:04:03Z",
  "data": {
    "reference": "txn_0192f4c1a7e84b6f9d2c3e5a7b9d1f30",
    "client_reference": "order_1042",
    "status": "successful",
    "product_code": "MTN-VTU",
    "network": "mtn",
    "service": "airtime",
    "destination": "+234********78",
    "amount": 500.00,
    "price": 2.00,
    "completed_at": "2026-10-06T12:04:03Z"
  }
}
```

A failed sale adds `failure_code`. `wallet.funded` carries `funding_id`, `amount`, `balance` and `gateway`; `wallet.low_balance` carries `balance` and `threshold`. Amounts are naira, numbers with two decimals.

## Checking the signature

Every request carries a `SimConnect-Signature` header: `t=<unix time>,v1=<signature>`. The signature is HMAC SHA-256 of `<t>.<raw body>` with your endpoint secret, in hex.

```js
import crypto from "node:crypto";

function verify(rawBody, header, secret) {
  const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
  return fresh && expected.length === v1.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
```

Refuse anything older than five minutes, so a captured request cannot be replayed.

## Retries

Answer with any `2xx` within ten seconds to confirm. Anything else, or no answer, is retried from 30 seconds later, doubling each time, for up to 8 attempts. Each event has an `id`, also sent as the `SimConnect-Event-Id` header, so handle it once even if it arrives twice. Failed deliveries can be replayed from the dashboard.
