> For clean Markdown of any page, append .md to the page URL. > For a complete documentation index, see https://docs.vtuagent.com/api-reference/overview/webhook/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 **`Authorization`** 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.