External API
Foreign Passport Bank Validation
Validate a South African bank account held by an African passport holder. Send one API request. VerifyNow sends the completed result to your signed webhook.
Endpoint
POST /foreign-bank-account-verificationAuthentication
Send your external API key in x-api-key.
Production Price
7 credits after a usable result completes.
Delivery
Asynchronous JSON webhook with an HMAC-SHA256 signature.
Create Your Webhook
A webhook is an HTTPS page in your backend that receives a POST when the bank result is ready. It can live at a path such as https://api.yourcompany.com/webhooks/verifynow.
1. Add the URL
Create a POST route on your backend. Choose Node.js, Python, or PHP from the working examples below.
2. Create a secret
Generate a random secret and save it as VERIFYNOW_WEBHOOK_SECRET on your server.
3. Deploy with HTTPS
Use the public HTTPS address from your hosting provider. VerifyNow validates the address before accepting the job.
4. Verify the signature
Build the HMAC from the timestamp and exact raw body. Compare it with x-verifynow-signature.
5. Save each event once
Store x-verifynow-event-id in your database. Repeated delivery of that ID returns HTTP 200.
6. Run a sandbox job
Sandbox sends a mock result and uses zero credits. Move to production after the callback appears in your logs.
First webhook test without a backend
Open Webhook.site and copy its unique HTTPS URL into webhookUrl. Run the sandbox request and watch the mock callback arrive. Use your own signed endpoint for production customer data.
Generate the secret
openssl rand -hex 32Put the output in your server environment as VERIFYNOW_WEBHOOK_SECRET. Send the same value as webhookSecret in the API request.
With Node.js installed, this command creates the same kind of secret:
node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"Signed headers
x-verifynow-event-idStable ID for duplicate handlingx-verifynow-timestampUnix timestamp in secondsx-verifynow-signaturev1=<hex HMAC-SHA256>Signed text: timestamp + "." + rawBody. Accept a timestamp within five minutes of your server clock.
Webhook Code
The signature uses the raw request bytes. Place the webhook route before a global JSON body parser in Node.js.
Node.js and Express
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const secret = process.env.VERIFYNOW_WEBHOOK_SECRET;
const seenEvents = new Set();
app.post(
'/webhooks/verifynow',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body.toString('utf8');
const timestamp = req.header('x-verifynow-timestamp') || '';
const received = req.header('x-verifynow-signature') || '';
const eventId = req.header('x-verifynow-event-id') || '';
const expected =
'v1=' +
crypto
.createHmac('sha256', secret)
.update(timestamp + '.' + rawBody)
.digest('hex');
const validLength = expected.length === received.length;
const validSignature =
validLength &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
if (!validSignature || !fresh) return res.sendStatus(401);
if (seenEvents.has(eventId)) return res.sendStatus(200);
seenEvents.add(eventId);
const event = JSON.parse(rawBody);
console.log(event.type, event.data.jobId, event.data.result);
return res.sendStatus(200);
},
);
app.use(express.json());
app.listen(3000);Python and Flask
import hashlib
import hmac
import os
import time
from flask import Flask, request
app = Flask(__name__)
secret = os.environ["VERIFYNOW_WEBHOOK_SECRET"].encode()
seen_events = set()
@app.post("/webhooks/verifynow")
def verifynow_webhook():
raw_body = request.get_data()
timestamp = request.headers.get("x-verifynow-timestamp", "")
received = request.headers.get("x-verifynow-signature", "")
event_id = request.headers.get("x-verifynow-event-id", "")
signed = timestamp.encode() + b"." + raw_body
expected = "v1=" + hmac.new(secret, signed, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(timestamp or 0)) <= 300
if not hmac.compare_digest(expected, received) or not fresh:
return "invalid signature", 401
if event_id in seen_events:
return "ok", 200
seen_events.add(event_id)
event = request.get_json()
print(event["type"], event["data"]["jobId"])
return "ok", 200PHP
<?php
$secret = getenv('VERIFYNOW_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_VERIFYNOW_TIMESTAMP'] ?? '';
$received = $_SERVER['HTTP_X_VERIFYNOW_SIGNATURE'] ?? '';
$eventId = $_SERVER['HTTP_X_VERIFYNOW_EVENT_ID'] ?? '';
$expected = 'v1=' . hash_hmac(
'sha256',
$timestamp . '.' . $rawBody,
$secret
);
$fresh = abs(time() - intval($timestamp)) <= 300;
if (!hash_equals($expected, $received) || !$fresh) {
http_response_code(401);
exit('invalid signature');
}
$event = json_decode($rawBody, true);
// Save $eventId in your database before processing the result.
// Return 200 when that event ID has already been saved.
http_response_code(200);
echo 'ok';Send the Validation
Start with mode: "sandbox". Production requests need a unique Idempotency-Key. A retry with the same key and request body returns the same job.
curl -X POST https://www.verifynow.co.za/api/external/foreign-bank-account-verification \
-H "x-api-key: $VERIFYNOW_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"firstName": "Tariro",
"surname": "Moyo",
"passportNumber": "AB123456",
"passportCountry": "ZW",
"bankName": "FNB",
"bankAccountNumber": "1234567890",
"bankBranchCode": "250655",
"bankAccountType": "current",
"consentConfirmed": true,
"purpose": "Customer bank account ownership check",
"webhookUrl": "https://api.example.com/webhooks/verifynow",
"webhookSecret": "$VERIFYNOW_WEBHOOK_SECRET",
"mode": "sandbox"
}'| Field | Value |
|---|---|
| passportCountry | Two-letter African ISO code, such as ZW |
| bankAccountType | unknown, current, cheque, savings, transmission, bond, or credit-card |
| consentConfirmed | true after the passport holder has authorised the check |
| purpose | Your lawful business reason for the validation |
| webhookUrl | Public HTTPS endpoint that accepts POST requests |
| webhookSecret | Random secret with at least 32 characters |
Supported banks
ABSA, AFRICAN BANK, CAPITEC BANK, DISCOVERY BANK, FINBOND MUTUAL BANK, FNB, GRINDROD BANK, INVESTEC, MERCANTILE BANK, NEDBANK, SASFIN BANK, STANDARD BANK, TYME BANK.
Accepted Response
HTTP 202 confirms that VerifyNow accepted the asynchronous job. Keep the jobId in your customer or payout record.
{
"success": true,
"jobId": "8ae57bb5-a015-49a4-ac89-1e25149ed907",
"status": "pending",
"mode": "sandbox",
"reportType": "foreign-bank",
"requiredCredits": 0,
"webhook": {
"delivery": "signed",
"eventTypes": [
"foreign_bank.verification.completed",
"foreign_bank.verification.failed"
]
}
}Completed Callback
The completed event contains match flags and masked bank data. A review outcome needs your normal manual review.
{
"id": "evt_8ae57bb5a01549a4ac891e25149ed907",
"type": "foreign_bank.verification.completed",
"createdAt": "2026-08-03T12:30:00.000Z",
"data": {
"jobId": "8ae57bb5-a015-49a4-ac89-1e25149ed907",
"status": "completed",
"mode": "production",
"reportType": "foreign-bank",
"creditsUsed": 7,
"result": {
"outcome": "verified",
"checks": {
"accountFound": true,
"accountOpen": true,
"accountNumberLengthValid": true,
"accountTypeMatch": true,
"passportNumberMatch": true,
"initialsMatch": true,
"surnameMatch": true,
"acceptsDebits": true,
"acceptsCredits": true
},
"bank": {
"name": "FNB",
"accountNumberLast4": "7890"
},
"passportCountry": "ZW"
}
}
}Processing Time
When processing starts
This dedicated route remains separate from standard real-time bank-account verification. HTTP 202 confirms that the job entered the processing queue. Store the job ID and your Idempotency-Key, then use the signed callback as the final result.
Observed test
One controlled Zimbabwean passport and FNB validation completed in 61 seconds. Processing time varies for each request.
Bank processing window
Results may arrive within 30 minutes. Some checks take more than three hours. Weekday requests submitted after 17:00 may wait until the next working day.
Terminal status
VerifyNow waits up to 48 hours for a final bank result. It then sends a signed failed event with error.code: "verification_timed_out".
Delivery Rules
HTTP response
Return any 2xx response within 15 seconds. After the first failed delivery, VerifyNow retries after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, and 24 hours.
Duplicate events
The same event ID can arrive again after a timeout. Save the ID before you update a payout or customer record.
Credits
Keep 7 credits available while a production job is processing. Billing occurs once a usable result is ready.
Support reference
Share the VerifyNow jobId in support requests. Keep passport numbers and full bank account numbers out of tickets.
See the main API reference for authentication, credits, errors, and general idempotency rules.