Appearance
Virtual Account webhook
POST payload sent to your merchant callback after VA settlement.
Headers
| Header | Description |
|---|---|
MCH-AUTH | live_auth_hash or test_auth_hash from merchant webhook settings |
Content-Type | application/json |
Payload
json
{
"status": "SUCCESS",
"data": {
"id": "DVA-1",
"tx_ref": "DVA-999357240831142552459563784150",
"amount": 98.5,
"charged_amount": 100,
"app_fee": 1.5,
"merchant_fee": 0,
"currency": "NGN",
"status": "SUCCESSFUL",
"payment_mode": "DVA",
"created_at": "2024-08-31T14:25:52.645613",
"customer": {
"consumer_id": ""
},
"data": {
"narration": " DOT/3/CR-FROM/John Doe - Test",
"accountNo": "1000119971",
"accountName": "John Doe",
"sourceBankCode": "999357",
"sourceAccountNo": "1000075721",
"sourceAccountName": "John Doe John",
"amount": 98.5,
"transactionDate": "2024-08-31T14:25:52.645613",
"totalAmount": 100,
"fee": 1.5,
"merchantReference": "",
"currency": "NGN"
}
},
"message": "SUCCESSFUL"
}Fee payer (who_pay_fee → stored pay_fee)
At VA settlement create, ViZO copies the merchant’s current who_pay_fee into merchant_va_transactions.pay_fee once. Later webhooks and transaction lookups use that stored flag only (option changes do not rewrite old deposits).
Stored pay_fee (from who_pay_fee at create) | Who pays | Webhook amount | Merchant credit |
|---|---|---|---|
true | Customer / payer | Net (charged_amount − app_fee), e.g. 197 when charged 200 and fee 3 | Always net |
false | Merchant | Gross (same as charged_amount) | Always net |
Sample above is the customer-pays shape (net amount).
Handling
- Return HTTP 2xx to acknowledge.
- With retry enabled, failed deliveries requeue about every 5 minutes, up to 5 attempts.
- Always verify
MCH-AUTHagainst your stored secret.
Sample receiver
ts
import http from 'node:http';
const SECRET = process.env.VIZO_MCH_AUTH ?? '';
http.createServer((req, res) => {
if (req.method !== 'POST') {
res.writeHead(405);
res.end();
return;
}
const auth = req.headers['mch-auth'];
if (auth !== SECRET) {
res.writeHead(401);
res.end('Unauthorized');
return;
}
const chunks: Buffer[] = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
console.log(body);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}).listen(8080);py
from flask import Flask, request, jsonify
import os
app = Flask(__name__)
SECRET = os.environ.get("VIZO_MCH_AUTH", "")
@app.post("/webhook")
def webhook():
if request.headers.get("MCH-AUTH") != SECRET:
return jsonify({"error": "Unauthorized"}), 401
payload = request.get_json(force=True)
print(payload)
return jsonify({"ok": True}), 200bash
curl -X POST 'https://your-app.example/webhook' \
-H 'Content-Type: application/json' \
-H 'MCH-AUTH: your-live-auth-hash' \
-d '{"status": "SUCCESS", "message": "SUCCESSFUL"}'php
<?php
// Example with a simple front-controller style handler
$secret = getenv('VIZO_MCH_AUTH') ?: '';
$auth = $_SERVER['HTTP_MCH_AUTH'] ?? '';
if ($auth !== $secret) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'Unauthorized']);
exit;
}
$payload = json_decode(file_get_contents('php://input'), true);
error_log(json_encode($payload));
http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['ok' => true]);