API Documentation
Homepage Get User Balance API Airtime API Data API Fetch Data Plans List Cable TV API Cable TV Verification API Fetch Cable TV Plans List Electricity API Electricity Verification API Fetch Electricty Plan IDs Bulk SMS API Data Pin API Exam Pin API Recharge Card API Internet Bundle API Fetch Internet Bundles List Transaction Query API Webhook API
× Homepage Get User Balance API Documentation Airtime API Documentation Data API Documentation Fetch Data Plans List API Documentation Cable TV API Documentation Cable TV Verification API Documentation Fetch Cable TV Plans List API Documentation Electricity API Documentation Electricity Verification API Documentation Fetch Electricty Plan IDs List API Documentation Bulk SMS API Documentation Data Pin API Documentation Exam Pin API Documentation Recharge Card API Documentation Internet Bundle API Documentation Fetch Internet Bundles List API Documentation Transaction Query API Documentation Webhook API Documentation

Vtunaija API Webhook Integration Guide

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.

Important: This documentation describes the webhook format and the integration requirements represented by this documentation page. Your receiving application must implement its own database updates, duplicate-event protection, and business logic.

Table of Contents

1. Overview

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.

Recommended approach: Verify the webhook, validate the payload, check for duplicate events, store or process the event, and then return a successful HTTP response.

2. Configuration

Webhook URL

Configure your webhook endpoint using the webhook settings available to your API account.

Your webhook endpoint should:

Do not use localhost in production. A webhook sender cannot normally reach http://localhost on your personal computer or private server network.

3. Supported Events

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.

4. Webhook Payload

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"
}

Payload Fields Defined:

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.

5. Webhook Headers

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.

6. Security and Signature Verification

Why verify the signature?

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.

Signature construction

The documented signature construction is:

signature_content = timestamp + "." + raw_request_body

signature = HMAC-SHA256(signature_content, webhook_secret)
Important: Use the exact raw HTTP request body for signature verification. Do not parse the JSON and then re-encode it before calculating the signature.

Verification sequence

  1. Read the raw HTTP request body.
  2. Read X-Webhook-Timestamp.
  3. Read X-Webhook-Signature.
  4. Construct timestamp + "." + rawBody.
  5. Calculate HMAC-SHA256 using your webhook secret.
  6. Compare the calculated signature with the received signature.
  7. Only process the webhook when verification succeeds.
  8. Check the event ID to prevent duplicate processing.

7. Implementation Examples

PHP

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'
]);

Laravel

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'
        ]);
    }
}

Node.js / Express

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

Python / Flask

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
    )
Python note: The comment syntax inside the actual Python example must use #. If you copy the explanatory comment above into your Python file, keep it as a Python comment.

cURL

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"

8. Testing

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.

Security warning: Do not enter a production webhook secret into a public documentation page. The browser must have access to the secret to calculate a client-side signature, which means the secret is exposed to the person using the browser. Use a dedicated private testing environment instead.

Webhook Tester

Leave blank only if your test endpoint does not require a signature.

Result

Browser tester limitations

9. Best Practices

1. Verify signatures

Do not process a signed webhook before verifying its signature when signature verification is configured.

2. Use event IDs for idempotency

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.

3. Do not trust the payload blindly

Validate the transaction ID, status, amount, event type and other fields before changing your own transaction records.

4. Preserve transaction history

Keep an audit trail of received webhook events so that transaction issues can be investigated later.

5. Return successful responses promptly

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.

6. Protect your webhook endpoint

Use HTTPS and signature verification. Do not expose your webhook secret in frontend JavaScript, source code repositories, screenshots or publicly accessible configuration files.

10. Frequently Asked Questions

How should I handle duplicate webhook events?

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.

What should I do when a transaction is successful?

Validate the webhook and update your own transaction record according to the status and transaction ID supplied in the event.

What should I do when a transaction fails?

Validate the webhook and update your transaction record according to your own application's failure and refund rules.

Should I verify the webhook signature?

Yes. When webhook signing is configured, verify the HMAC-SHA256 signature before processing the event.

Can I process a webhook more than once?

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.

Can I use localhost?

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.

What if my webhook endpoint is temporarily unavailable?

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.

What should I use to test the webhook?

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.