Webhooks allow your application to receive HTTP notifications when the status of a supported transaction changes. Instead of repeatedly polling the API, your application can receive the transaction event at your configured webhook endpoint.
A webhook is an HTTP POST request sent to your application when a transaction event occurs.
Your webhook endpoint should accept JSON requests and return a successful HTTP response after it has safely accepted the notification.
Configure your webhook endpoint using the webhook settings available to your API account.
Your webhook endpoint should:
http://localhost on your personal computer or private server
network.
| Event Type | Transaction Status | Meaning |
|---|---|---|
transaction.processing |
processing |
The transaction is still being processed. |
transaction.success |
successful |
The transaction has completed successfully. |
transaction.failed |
failed |
The transaction has failed. |
Webhook requests use JSON. A typical payload has the following structure:
{
"event_id": "ev_abc123def456",
"event_type": "transaction.success",
"transaction_id": "req_20240101_xyz",
"transaction_internal_id": 123456,
"service": "DATA",
"provider_reference": "VT-20240101-ABC123",
"status": "successful",
"amount": "1000.00",
"plan_amount": "1000.00",
"mobile_number": "08012345678",
"network": "MTN",
"data_type": "SME",
"api_response": "Data delivered successfully",
"balance_before": "5000.00",
"balance_after": "4000.00",
"created_at": "2024-01-01T10:30:00Z",
"user_type": "Basic"
}
| Field | Type | Description |
|---|---|---|
event_id |
String | Unique identifier for the webhook event. Store this value to prevent processing the same event more than once. |
event_type |
String |
Event name, such as
transaction.success,
transaction.failed, or
transaction.processing.
|
transaction_id |
String | This is the same transaction/request identifier that you sent to us in your original purchase request. |
transaction_internal_id |
Number | This is our Internal transaction/request identifier for the transaction. |
service |
String |
Service associated with the transaction, for example
DATA or AIRTIME or INTERNETBUNDLES or CABLETV or ELECTRICITYor RECHARGECARD or EXAMPIN or DATACARD or BULKSMS.
|
provider_reference |
String | Provider transaction reference, when available. |
status |
String | Current transaction status. It can be either successful or failed or processing or refunded. |
amount |
String / Decimal | Amount associated with the transaction. |
plan_amount |
String / Decimal | Plan amount associated with the transaction, when available. |
mobile_number |
String | Customer's mobile number, or cable tv iuc number, or electricity meter number associated with the transaction. |
network |
String | Mobile network, or cable tv name or electricity company name associated with the transaction. |
data_type |
String | Data plan type, when applicable. |
api_response |
String | Response or message associated with processing. |
balance_before |
String / Decimal | Wallet balance before the transaction, when supplied. |
balance_after |
String / Decimal | Wallet balance after the transaction, when supplied. |
created_at |
String | Transaction creation timestamp in ISO 8601 format. |
user_type |
String | User pricing/account type, when supplied. |
The webhook request should contain the following headers where applicable:
| Header | Purpose |
|---|---|
Content-Type |
Identifies the request body as JSON. |
X-Webhook-Timestamp |
Timestamp associated with the webhook request. |
X-Webhook-Event-ID |
Unique webhook event identifier. |
X-Webhook-Signature |
HMAC-SHA256 signature used to authenticate the request when webhook signing is configured. |
A webhook endpoint is publicly accessible. Without authentication, another person could potentially send a fake HTTP request to the endpoint.
HMAC-SHA256 allows your application to verify that the request was generated using the configured webhook secret.
The documented signature construction is:
signature_content = timestamp + "." + raw_request_body
signature = HMAC-SHA256(signature_content, webhook_secret)
X-Webhook-Timestamp.X-Webhook-Signature.timestamp + "." + rawBody.The following is a complete basic PHP webhook receiver example. Replace the secret and database handling with your own production configuration.
<?php
declare(strict_types=1);
header('Content-Type: application/json');
$webhookSecret = 'YOUR_WEBHOOK_SECRET';
$rawBody = file_get_contents('php://input');
if ($rawBody === false) {
http_response_code(400);
echo json_encode(['error' => 'Unable to read request body']);
exit;
}
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$eventId = $_SERVER['HTTP_X_WEBHOOK_EVENT_ID'] ?? '';
if ($timestamp === '' || $signature === '' || $eventId === '') {
http_response_code(400);
echo json_encode(['error' => 'Missing required webhook headers']);
exit;
}
$signatureContent = $timestamp . '.' . $rawBody;
$expectedSignature = hash_hmac(
'sha256',
$signatureContent,
$webhookSecret
);
if (!hash_equals($expectedSignature, $signature)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid webhook signature']);
exit;
}
$payload = json_decode($rawBody, true);
if (!is_array($payload)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid JSON payload']);
exit;
}
$receivedEventId = (string)($payload['event_id'] ?? $eventId);
if ($receivedEventId === '') {
http_response_code(400);
echo json_encode(['error' => 'Missing event_id']);
exit;
}
/*
* IMPORTANT:
* Before processing the transaction, check your database to determine
* whether this event_id has already been processed.
*
* If it has already been processed, return HTTP 200 and do not process
* the transaction again.
*/
/*
* Example transaction information:
*/
$transactionId = (string)($payload['transaction_id'] ?? '');
$eventType = (string)($payload['event_type'] ?? '');
$status = (string)($payload['status'] ?? '');
if ($transactionId === '' || $eventType === '' || $status === '') {
http_response_code(400);
echo json_encode(['error' => 'Incomplete webhook payload']);
exit;
}
/*
* Process the event here.
*
* Example:
*
* if ($status === 'successful') {
* // Mark transaction as successful.
* } elseif ($status === 'failed') {
* // Mark transaction as failed.
* } elseif ($status === 'processing') {
* // Mark transaction as processing.
* }
*
* After successful processing, store $receivedEventId in your database.
*/
http_response_code(200);
echo json_encode([
'status' => 'success'
]);
Example Laravel route:
use App\Http\Controllers\WebhookController;
use Illuminate\Support\Facades\Route;
Route::post(
'/webhooks/Vtunaija',
[WebhookController::class, 'handle']
);
Example controller:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class WebhookController extends Controller
{
public function handle(Request $request): JsonResponse
{
$secret = config('services.Vtunaija.webhook_secret');
if (!$secret) {
return response()->json([
'error' => 'Webhook secret is not configured'
], 500);
}
$rawBody = $request->getContent();
$timestamp = (string)$request->header(
'X-Webhook-Timestamp',
''
);
$signature = (string)$request->header(
'X-Webhook-Signature',
''
);
$eventId = (string)$request->header(
'X-Webhook-Event-ID',
''
);
if (
$timestamp === '' ||
$signature === '' ||
$eventId === ''
) {
return response()->json([
'error' => 'Missing required webhook headers'
], 400);
}
$signatureContent = $timestamp . '.' . $rawBody;
$expectedSignature = hash_hmac(
'sha256',
$signatureContent,
$secret
);
if (!hash_equals($expectedSignature, $signature)) {
return response()->json([
'error' => 'Invalid webhook signature'
], 401);
}
$payload = json_decode($rawBody, true);
if (!is_array($payload)) {
return response()->json([
'error' => 'Invalid JSON payload'
], 400);
}
$payloadEventId = (string)(
$payload['event_id'] ?? $eventId
);
if ($payloadEventId === '') {
return response()->json([
'error' => 'Missing event_id'
], 400);
}
/*
* Check your database here for an existing event_id.
*
* If already processed:
*
* return response()->json([
* 'status' => 'success'
* ]);
*/
/*
* Process the transaction here.
*
* After successful processing, store the event ID in a
* table having a UNIQUE constraint on event_id.
*/
return response()->json([
'status' => 'success'
]);
}
}
const express = require('express');
const crypto = require('crypto');
const app = express();
const webhookSecret = process.env.WEBHOOK_SECRET;
app.use(express.json({
verify: (req, res, buffer) => {
req.rawBody = buffer.toString('utf8');
}
}));
function verifySignature(body, timestamp, receivedSignature) {
if (!body || !timestamp || !receivedSignature) {
return false;
}
if (!webhookSecret) {
return false;
}
const signatureContent = timestamp + '.' + body;
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(signatureContent, 'utf8')
.digest('hex');
const expectedBuffer = Buffer.from(
expectedSignature,
'utf8'
);
const receivedBuffer = Buffer.from(
receivedSignature,
'utf8'
);
if (expectedBuffer.length !== receivedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(
expectedBuffer,
receivedBuffer
);
}
app.post('/webhooks/Vtunaija', async (req, res) => {
try {
const rawBody = req.rawBody || '';
const timestamp =
req.get('X-Webhook-Timestamp') || '';
const signature =
req.get('X-Webhook-Signature') || '';
const eventId =
req.get('X-Webhook-Event-ID') || '';
if (!eventId) {
return res.status(400).json({
error: 'Missing webhook event ID'
});
}
if (!verifySignature(
rawBody,
timestamp,
signature
)) {
return res.status(401).json({
error: 'Invalid webhook signature'
});
}
const payload = req.body;
if (
!payload ||
typeof payload !== 'object'
) {
return res.status(400).json({
error: 'Invalid JSON payload'
});
}
/*
* Check eventId in your database before processing.
*
* If the event has already been processed, return 200.
*/
/*
* Process the transaction here.
*
* After successful processing, save the event ID.
*/
return res.status(200).json({
status: 'success'
});
} catch (error) {
console.error(
'Webhook processing error:',
error
);
return res.status(500).json({
error: 'Processing failed'
});
}
});
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(
`Webhook server listening on port ${port}`
);
});
import os
import hmac
import hashlib
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = os.environ.get(
"WEBHOOK_SECRET",
""
)
def verify_signature(
raw_body: bytes,
timestamp: str,
received_signature: str
) -> bool:
if not timestamp or not received_signature:
return False
if not WEBHOOK_SECRET:
return False
signature_content = (
timestamp.encode("utf-8")
+ b"."
+ raw_body
)
expected_signature = hmac.new(
WEBHOOK_SECRET.encode("utf-8"),
signature_content,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(
expected_signature,
received_signature
)
@app.route(
"/webhooks/Vtunaija",
methods=["POST"]
)
def handle_webhook():
try:
raw_body = request.get_data()
timestamp = request.headers.get(
"X-Webhook-Timestamp",
""
)
signature = request.headers.get(
"X-Webhook-Signature",
""
)
event_id = request.headers.get(
"X-Webhook-Event-ID",
""
)
if not event_id:
return jsonify({
"error": "Missing webhook event ID"
}), 400
if not verify_signature(
raw_body,
timestamp,
signature
):
return jsonify({
"error": "Invalid webhook signature"
}), 401
payload = request.get_json(
silent=True
)
if not isinstance(payload, dict):
return jsonify({
"error": "Invalid JSON payload"
}), 400
/*
* Check event_id in your database before processing.
*
* If already processed, return HTTP 200.
*
* Process the transaction here.
*
* After successful processing, store event_id.
*/
return jsonify({
"status": "success"
}), 200
except Exception as error:
app.logger.exception(
"Webhook processing error"
)
return jsonify({
"error": "Processing failed"
}), 500
if __name__ == "__main__":
app.run(
host="0.0.0.0",
port=int(
os.environ.get("PORT", "3000")
),
debug=False
)
#. If you copy the explanatory comment above into
your Python file, keep it as a Python comment.
The following example demonstrates how a signed JSON webhook request can be generated and sent from a Unix-like environment.
WEBHOOK_URL="https://example.com/webhooks/Vtunaija"
SECRET="YOUR_WEBHOOK_SECRET"
TIMESTAMP=$(date +%s)
PAYLOAD='{
"event_id": "ev_test_123",
"event_type": "transaction.success",
"transaction_id": "req_test_abc",
"transaction_internal_id": 999999,
"service": "DATA",
"provider": "test",
"provider_reference": "TEST-123",
"status": "successful",
"amount": "1000.00",
"plan_amount": "1000.00",
"mobile_number": "08012345678",
"network": "MTN",
"data_type": "SME",
"api_response": "Test webhook",
"balance_before": "5000.00",
"balance_after": "4000.00",
"created_at": "2026-08-27T19:00:00Z",
"profit": "100.00",
"user_type": "Basic"
}'
SIGNATURE_CONTENT="${TIMESTAMP}.${PAYLOAD}"
SIGNATURE=$(printf '%s' "$SIGNATURE_CONTENT" \
| openssl dgst -sha256 \
-hmac "$SECRET" \
-hex \
| awk '{print $2}')
curl -X POST "$WEBHOOK_URL" \
-H "Content-Type: application/json" \
-H "X-Webhook-Timestamp: $TIMESTAMP" \
-H "X-Webhook-Event-ID: ev_test_123" \
-H "X-Webhook-Signature: $SIGNATURE" \
--data "$PAYLOAD"
The browser tester below can generate a test JSON payload and, when a secret is supplied, calculate the HMAC-SHA256 signature using the browser's Web Crypto API.
Do not process a signed webhook before verifying its signature when signature verification is configured.
Webhook consumers should be designed so that receiving the same event more than once does not cause the transaction to be processed twice.
A database table containing the webhook event ID with a UNIQUE constraint is a common approach.
Validate the transaction ID, status, amount, event type and other fields before changing your own transaction records.
Keep an audit trail of received webhook events so that transaction issues can be investigated later.
Your webhook endpoint should acknowledge successfully received events promptly. Lengthy business processing should preferably be moved to a queue or background worker where your infrastructure supports it.
Use HTTPS and signature verification. Do not expose your webhook secret in frontend JavaScript, source code repositories, screenshots or publicly accessible configuration files.
Use event_id as the event's idempotency identifier. Store
processed event IDs in your database and do not process an event again
when the same ID has already been handled.
Validate the webhook and update your own transaction record according to the status and transaction ID supplied in the event.
Validate the webhook and update your transaction record according to your own application's failure and refund rules.
Yes. When webhook signing is configured, verify the HMAC-SHA256 signature before processing the event.
Your webhook receiver should be idempotent. If the same event is received multiple times, the result should not cause duplicate wallet debits, duplicate refunds, duplicate transaction records or other unintended side effects.
Localhost can be used for local development when you have a suitable tunneling/development setup, but a production webhook sender cannot normally reach your computer's localhost directly.
Your receiving application should be designed to tolerate repeated delivery attempts and duplicate events. Do not assume that a webhook will always be delivered only once.
For simple testing, cURL is recommended because it allows you to generate the exact request body and signature yourself. The browser tester is provided as a convenience, but browser CORS restrictions may prevent it from reaching some webhook endpoints.