VerifyNow

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-verification

Authentication

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. 1. Add the URL

    Create a POST route on your backend. Choose Node.js, Python, or PHP from the working examples below.

  2. 2. Create a secret

    Generate a random secret and save it as VERIFYNOW_WEBHOOK_SECRET on your server.

  3. 3. Deploy with HTTPS

    Use the public HTTPS address from your hosting provider. VerifyNow validates the address before accepting the job.

  4. 4. Verify the signature

    Build the HMAC from the timestamp and exact raw body. Compare it with x-verifynow-signature.

  5. 5. Save each event once

    Store x-verifynow-event-id in your database. Repeated delivery of that ID returns HTTP 200.

  6. 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 32

Put 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 handling
x-verifynow-timestampUnix timestamp in seconds
x-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", 200

PHP

<?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"
  }'
FieldValue
passportCountryTwo-letter African ISO code, such as ZW
bankAccountTypeunknown, current, cheque, savings, transmission, bond, or credit-card
consentConfirmedtrue after the passport holder has authorised the check
purposeYour lawful business reason for the validation
webhookUrlPublic HTTPS endpoint that accepts POST requests
webhookSecretRandom 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.