> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.vtuagent.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.vtuagent.com/_mcp/server.

# Webhook

VTUAgent notifies your server when an airtime or data purchase changes state, so you don't have to poll.

## Register your endpoint

```http
POST /v1/webhook/set
Authorization: Bearer {your_api_key}
Content-Type: application/json

{ "webhook_url": "https://example.com/vtuagent/webhook" }
```

One URL per account; calling again replaces it. Retrieve the current value with `GET /v1/webhook/get`. Your endpoint must be a publicly reachable absolute URL — use HTTPS.

## Delivery

For each event VTUAgent sends a single `POST` to your URL with `Content-Type: application/json` and a 10-second timeout.

* **No retries.** If your endpoint is down or responds slowly, the event is not resent. If you miss a delivery, fetch the outcome with `POST /v1/transaction/status`.
* **No&#x20;**`Authorization`**&#x20;header.** Authenticity is carried by the `hash` field in the body (see below).
* Respond with HTTP `200` as quickly as possible and do your own processing afterwards.

## Events

| `event`                    | Sent by                       | `status`                                                | When                                                                    |
| -------------------------- | ----------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------- |
| `airtime.purchase.success` | `POST /v1/airtime/purchase`   | `success`                                               | Provider confirmed delivery                                             |
| `airtime.purchase.failed`  | `POST /v1/airtime/purchase`   | `failed`                                                | Provider rejected or was unreachable — wallet refunded                  |
| `airtime.purchase.pending` | `POST /v1/airtime/purchase`   | `pending`                                               | Provider still processing                                               |
| `data.purchase.success`    | `POST /v1/data/purchase`      | `success`                                               | Provider confirmed delivery                                             |
| `data.purchase.failed`     | `POST /v1/data/purchase`      | `failed`                                                | Provider rejected — wallet refunded                                     |
| `transaction.requery`      | `POST /v1/transaction/status` | `success`, `failed`, `pending`, `initiated`, `refunded` | Every time you call the status endpoint; mirrors the stored transaction |

Cable TV and electricity purchases do **not** emit webhooks. Their responses are synchronous; if one returns `202 pending`, poll `POST /v1/transaction/status`.

## Payload

The body always contains exactly these eight fields:

```json
{
  "event": "data.purchase.success",
  "reference": "202609131244GPTFV",
  "status": "success",
  "amount": "1454.98",
  "product_type": "DATA",
  "details": "Data purchase successful - 5.5GB 2-Days for 07041747115",
  "hash": "5d6a5ec98b7f07a4d0e09cd8016d0619eb8c162ede52a5319cd59a13ddf442a5",
  "timestamp": "2026-09-13T11:44:26.938764932Z"
}
```

| Field          | Type   | Description                                                                                                                         |
| -------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| `event`        | string | One of the events above                                                                                                             |
| `reference`    | string | The `request_ref` you sent when purchasing                                                                                          |
| `status`       | string | Lowercase outcome — see the events table                                                                                            |
| `amount`       | string | Naira, decimal string                                                                                                               |
| `product_type` | string | `AIRTIME`, `DATA`, `CABLE_TV`, `ELECTRICITY`                                                                                        |
| `details`      | string | Human-readable outcome. Same text as the API response `message`; for `transaction.requery` it is the stored transaction description |
| `hash`         | string | Authenticity digest — 64 lowercase hex characters (see below)                                                                       |
| `timestamp`    | string | RFC 3339, UTC, nanosecond precision                                                                                                 |

## Verifying the `hash`

Every webhook carries a SHA-256 digest of your API key and the transaction reference, joined with a colon. Because only VTUAgent and you know the API key, a matching digest confirms the webhook came from VTUAgent and refers to a transaction on your account.

```
hash = hex( SHA-256( "{your_api_key}:{reference}" ) )
```

Recompute it and compare to the received value with a constant-time comparison. Reject the request if they differ.

```python
import hashlib, hmac

def is_valid(payload: dict, api_key: str) -> bool:
    expected = hashlib.sha256(f"{api_key}:{payload['reference']}".encode()).hexdigest()
    return hmac.compare_digest(expected, payload["hash"])
```

```javascript
const { createHash, timingSafeEqual } = require("node:crypto");

function isValid(payload, apiKey) {
  const expected = createHash("sha256").update(`${apiKey}:${payload.reference}`).digest("hex");
  return expected.length === payload.hash.length &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(payload.hash));
}
```

```php
$expected = hash('sha256', $apiKey . ':' . $payload['reference']);
$valid = hash_equals($expected, $payload['hash']);
```

If you have more than one active API key, the hash is computed with the most recently created one — update your webhook handler when you rotate keys.

## Handling duplicates

`transaction.requery` fires on every call to the status endpoint, so you may receive the same `reference` more than once, and a `pending` event is normally followed by `success` or `failed`. Key your processing on `reference` and apply state transitions idempotently — never move a transaction from `success` back to `pending`, and never fulfil the same `reference` twice.

## Checklist

1. Register an HTTPS URL with `POST /v1/webhook/set`.
2. On each delivery: parse the JSON, verify `hash`, respond `200` immediately.
3. Process asynchronously, matching `reference` to your own records.
4. Handle repeats idempotently; there are no retries, so use `POST /v1/transaction/status` to recover any missed event.