Send a payout
Transfer funds to a recipient's bank account with the Payouts API. There is no customer-facing step, so the whole integration lives in your backend.
On this page14 sections
- Before you start
- How payout integration works
- Environments
- Authentication
- Core integration
- Step 1: Check payout availability (optional but recommended)
- Step 2: Encrypt the account number
- Step 3: Generate the request hash
- Step 4: Submit the payout request
- Step 5: Implement the verification webhook
- Step 6: Handle the notification response
- Step 7: Confirm payout status via API (recommended)
- Payout outcome flow
- Next steps
Build with AI 1 package
A build package is every page for one task, with the API operations they use. Copy the prompt into a coding assistant, or hand it the package itself: slim links to each page, full inlines all of them in one document.
- Send a payoutEverything needed to pay money out to a customer's bank account or voucher with the Payouts API, including how to exercise the failure paths before going live.
This guide walks you through integrating Ozow payoutsPayout Money sent from a merchant to a bank account. Unlike a refund, a payout is not tied to a payment anyone made you, so you can pay anyone with a bank account. Payouts draw on your float rather than on your incoming payments, and they are not self-service: they need approval from Ozow and testing in staging first. using the Payouts API. Payouts are merchant-initiated transfers to a recipient's bank account. There is no customer-facing payment step, the entire integration lives in your backend.
This guide uses the Payouts API, the current API for all payouts. Payouts are separate from payinsPayin A payment made by a consumer to a merchant. The direction most of this site is about: money coming in. Its counterpart is a payout, which sends money out and is not tied to any payment anyone made you. and have their own API: the One API you use to accept payments doesn't handle payouts.
Before you start
Payout integration requires explicit Ozow approval before you can begin.
You must be approved by Ozow's onboarding team before starting a payout integration. Payout credentials are not issued until approval is in place. To request approval, contact your account manager or support@ozow.com.
Staging testing is mandatory before production access is granted.
Unlike payins, payouts cannot be reversed once processed; if funds are sent to the wrong account or with incorrect details, they cannot be recovered. Completing the mandatory test cases in the staging environment protects you as a merchant by ensuring your integration handles all scenarios correctly before real funds are involved. Production access will not be granted until Ozow has reviewed and signed off your test results. See Payout test cases for the full requirements.
Once approved, ensure you have the following in place:
- Your Payout API key and site codeSite code The unique code for a site registered under a merchant. A site is a place to transact: a website, or a branch of a store. A merchant can have several, and each transaction names the one it belongs to, so sending the wrong code files the payment against the wrong place., provided by Ozow after approval. The Payout API key is specific to payout integrations and is different from your standard API key. It is not available by default.
- Your verification webhookWebhook A URL of yours that Ozow calls when something happens, rather than you polling to find out. The call carries no credential of yours and arrives at a public URL, so authenticate it before acting on it: a hash field on the Payments API, a Svix signature on One API. endpoint is set up and publicly accessible via HTTPS
- Your notification URL is set up and publicly accessible via HTTPS
- Your Ozow floatFloat The balance held with Ozow that payouts and refunds are paid out of. Both draw on it, and neither will process while it is empty. Payins do not need one, so if you only take payments you never meet it. is funded, Ozow uses your float balance to process payouts. See Float top-up guide to load funds into your float before going live.
How payout integration works
Environments
| Environment | Base URL | Mock base URL | Dashboard |
|---|---|---|---|
| Production | https:/ |
https:/ |
dash.ozow.com |
| Staging | https:/ |
https:/ |
stagingdash.ozow.com |
Mock API
The mock API lets you test your integration without processing real payouts. Payout requests submitted to mock endpoints are not visible on the Dashboard.
Authentication
All Payouts API requests require two headers:
| Header | Description |
|---|---|
Site |
Your Ozow site code |
Api |
Your Payout API key, provided by Ozow after payout approval |
Core integration
Step 1: Check payout availability (optional but recommended)
Before submitting a payout request, check which banks are available and whether real-time clearingReal-Time Clearing Payments that clear immediately rather than waiting for a batch. A batch run settles at set times through the day; a Real-Time Clearing payment moves the funds between the two bank accounts as it is made, so the recipient can rely on them straight away.PayInc (RTC) is supported for the destination bank. This lets you show accurate processing times to your customers.
You do not need to call this endpoint before every payout request. We recommend checking availability periodically, at least once a week, to stay informed of any changes. Changes to bank availability are infrequent but do occur, and building this check into a scheduled job rather than a per-request call is the recommended approach.
GET https://payoutsapi.ozow.com/v1/getavailablebanks
SiteCode: YOUR_SITE_CODE
ApiKey: YOUR_API_KEY
Add ?rtconly=true to return only RTC-enabled banks.
Response example
[
{
"bankGroupId": "00000000-0000-0000-0000-000000000000",
"bankGroupName": "ABSA",
"universalBranchCode": "632005"
},
{
"bankGroupId": "00000000-0000-0000-0000-000000000000",
"bankGroupName": "Capitec Bank",
"universalBranchCode": "470010"
},
{
"bankGroupId": "00000000-0000-0000-0000-000000000000",
"bankGroupName": "FNB",
"universalBranchCode": "250655"
},
{
"bankGroupId": "00000000-0000-0000-0000-000000000000",
"bankGroupName": "Nedbank",
"universalBranchCode": "198765"
},
{
"bankGroupId": "00000000-0000-0000-0000-000000000000",
"bankGroupName": "Standard Bank",
"universalBranchCode": "051001"
}
]
Use the bankGroupId from this response as the BankGroupId in your payout request.
Note
RTC is not available in the staging environment. Set IsRtc to false for all
staging tests.
Step 2: Encrypt the account number
Security requirement
Ozow requires that the destination bank account number is encrypted before being included in the payout request. Never send a plain text account number.
AES-256-CBC is the standard and required encryption method for all payout integrations. This is the method you must use unless Ozow has specifically approved an alternative for your account.
RSA/OAEP-SHA256 encryption
An alternative RSA-based encryption method is available for specific use cases that meet Ozow's security requirements. This method is not available by default and requires explicit approval from Ozow before it can be used. If you believe RSA encryption is appropriate for your integration, discuss this with your account manager before proceeding. Do not implement RSA encryption without prior approval.
AES-256-CBC encryption
Parameters
- Key size: 256
- Mode: Cipher Block Chaining (CBC)
- Padding: PKCS7
Generating the IV
The initialisation vector (IV) is derived from an SHA512SHA-512 A hashing algorithm. Ozow uses it to sign the values in a request or a notification so you can tell that they arrived unaltered and came from us. Hashing is one-way: the hash cannot be turned back into what produced it.Wikipedia hash of the concatenation of:
- Merchant reference
- Amount in cents (e.g.
10000for R100.00) - Your encryption key
Take the first 16 bytes of the SHA512 hash as the IV.
Important
Generate and persist a unique encryption key per payout request. Never reuse an encryption key across multiple payout requests.
using System.Security.Cryptography;
using System.Text;
var plaintextEncryptionKey = "YOUR_ENCRYPTION_KEY";
var plaintextAccountNumber = "YOUR_ACCOUNT_NUMBER";
var merchantReference = "ORDER-001";
var payoutAmount = 100.00M;
var ivString = string.Concat(
merchantReference,
Convert.ToInt32(payoutAmount * 100),
plaintextEncryptionKey
);
var encryptedAccountNumber = EncryptAes(plaintextAccountNumber, plaintextEncryptionKey, ivString);
string EncryptAes(string data, string key, string ivString)
{
byte[] dataBytes = Encoding.UTF8.GetBytes(data);
string iv = GetSha512Hash(ivString.ToLower()).Substring(0, 16);
while (key.Length < 32)
key += key;
using var aes = new AesCryptoServiceProvider();
aes.Key = Encoding.UTF8.GetBytes(key.Substring(0, 32));
aes.Mode = CipherMode.CBC;
aes.IV = Encoding.UTF8.GetBytes(iv);
var encryptor = aes.CreateEncryptor();
var encryptedBytes = encryptor.TransformFinalBlock(dataBytes, 0, dataBytes.Length);
return Convert.ToBase64String(encryptedBytes);
}
string GetSha512Hash(string input)
{
using SHA512 sha = new SHA512CryptoServiceProvider();
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(input));
var sb = new StringBuilder();
foreach (var b in bytes)
sb.Append(b.ToString("x2"));
return sb.ToString();
}
<?php
$plaintextEncryptionKey = "YOUR_ENCRYPTION_KEY";
$plaintextAccountNumber = "YOUR_ACCOUNT_NUMBER";
$merchantReference = "ORDER-001";
$amount = 10000; // Amount in cents
$ivString = strtolower($merchantReference . $amount . $plaintextEncryptionKey);
$ivHash = hash("sha512", $ivString, false);
$iv = substr($ivHash, 0, 16);
$key = $plaintextEncryptionKey;
while (strlen($key) < 32) {
$key .= $plaintextEncryptionKey;
}
$key = substr($key, 0, 32);
$encrypted = openssl_encrypt(
$plaintextAccountNumber,
"AES-256-CBC",
$key,
OPENSSL_RAW_DATA,
$iv,
);
$encryptedAccountNumber = base64_encode($encrypted);
echo "Encrypted: " . $encryptedAccountNumber;
?>
const crypto = require("crypto");
const plaintextEncryptionKey = "YOUR_ENCRYPTION_KEY";
const plaintextAccountNumber = "YOUR_ACCOUNT_NUMBER";
const merchantReference = "ORDER-001";
const payoutAmount = 100.00;
const ivString = (
merchantReference +
Math.round(payoutAmount * 100) +
plaintextEncryptionKey
).toLowerCase();
const iv = crypto
.createHash("sha512")
.update(ivString)
.digest("hex")
.substring(0, 16);
let key = plaintextEncryptionKey;
while (key.length < 32) key += plaintextEncryptionKey;
key = key.substring(0, 32);
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
const encrypted = Buffer.concat([
cipher.update(plaintextAccountNumber, "utf8"),
cipher.final(),
]);
const encryptedAccountNumber = encrypted.toString("base64");
console.log("Encrypted:", encryptedAccountNumber);
import hashlib
import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
plaintext_encryption_key = "YOUR_ENCRYPTION_KEY"
plaintext_account_number = "YOUR_ACCOUNT_NUMBER"
merchant_reference = "ORDER-001"
payout_amount = 100.00
iv_string = (
merchant_reference + str(round(payout_amount * 100)) + plaintext_encryption_key
).lower()
iv = hashlib.sha512(iv_string.encode()).hexdigest()[:16]
key = plaintext_encryption_key
while len(key) < 32:
key += plaintext_encryption_key
key = key[:32].encode("utf-8")
cipher = AES.new(key, AES.MODE_CBC, iv.encode("utf-8"))
encrypted = cipher.encrypt(
pad(plaintext_account_number.encode("utf-8"), AES.block_size)
)
encrypted_account_number = base64.b64encode(encrypted).decode("utf-8")
print("Encrypted:", encrypted_account_number)
Step 3: Generate the request hash
Generate a SHA512 hash to sign the payout request.
Critical, field order matters
Fields must be concatenated in exactly the order shown below. Using the wrong order is the most common cause of hash failures. Only include fields that have a value, exclude empty fields entirely.
Hash field concatenation order
| Position | Field |
|---|---|
| 1 | site |
| 2 | amount in cents, so 10000 for R100.00 |
| 3 | merchant |
| 4 | customer |
| 5 | is |
| 6 | notify |
| 7 | banking |
| 8 | banking, encrypted |
| 9 | banking |
| 10 | Your API key |
Steps
- Concatenate the fields above in order
- Convert the entire string to lowercase
- Generate a SHA512 hash of the lowercase string
Note
Amount must be in cents as an integer. Boolean values must be the strings true or
false, not 1 or 0.
Important
Round the amount to cents, do not truncate it. 1.15 * 100 is
114.99999999999999 in binary floating point, so truncating gives 114 where Ozow calculated
115, and the hash is rejected. Use your language's rounding function, or hold the amount in
cents from the start.
using System.Security.Cryptography;
using System.Text;
var siteCode = "YOUR_SITE_CODE";
var amount = 100.00;
var merchantReference = "ORDER-001";
var customerBankReference = "ABC123";
var apiKey = "YOUR_API_KEY";
var isRtc = false;
var notifyUrl = "https://yourstore.com/notify";
var bankGroupId = "YOUR_BANK_GROUP_ID";
var accountNumber = "YOUR_ENCRYPTED_ACCOUNT_NUMBER";
var branchCode = "YOUR_BRANCH_CODE";
var inputString = string.Concat(
siteCode,
Convert.ToInt32(amount * 100),
merchantReference,
customerBankReference,
isRtc,
notifyUrl,
bankGroupId,
accountNumber,
branchCode,
apiKey
)
.ToLower();
using SHA512 sha = new SHA512CryptoServiceProvider();
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(inputString));
var hash = string.Concat(bytes.Select(b => b.ToString("x2")));
Console.WriteLine($"HashCheck: {hash}");
<?php
$inputString = strtolower(
$siteCode .
round($amount * 100) .
$merchantReference .
$customerBankReference .
"false" .
$notifyUrl .
$bankGroupId .
$accountNumber .
$branchCode .
$apiKey,
);
$hashCheck = hash("sha512", $inputString);
echo "HashCheck: " . $hashCheck;
?>
const crypto = require("crypto");
const inputString = (
siteCode +
Math.round(amount * 100) +
merchantReference +
customerBankReference +
isRtc +
notifyUrl +
bankGroupId +
accountNumber +
branchCode +
apiKey
).toLowerCase();
const hashCheck = crypto.createHash("sha512").update(inputString).digest("hex");
console.log("HashCheck:", hashCheck);
import hashlib
input_string = (
site_code
+ str(round(amount * 100))
+ merchant_reference
+ customer_bank_reference
+ str(is_rtc).lower()
+ notify_url
+ bank_group_id
+ account_number
+ branch_code
+ api_key
).lower()
hash_check = hashlib.sha512(input_string.encode()).hexdigest()
print("HashCheck:", hash_check)
Step 4: Submit the payout request
POST https://payoutsapi.ozow.com/v1/requestpayout
SiteCode: YOUR_SITE_CODE
ApiKey: YOUR_API_KEY
Content-Type: application/json
Request example
{
"siteCode": "YOUR_SITE_CODE",
"amount": 100.00,
"merchantReference": "ORDER-001",
"customerBankReference": "ABC123",
"isRtc": false,
"notifyUrl": "https://yourstore.com/notify",
"bankingDetails": {
"bankGroupId": "YOUR_BANK_GROUP_ID",
"accountNumber": "YOUR_ENCRYPTED_ACCOUNT_NUMBER",
"branchCode": "YOUR_BRANCH_CODE"
},
"hashCheck": "YOUR_GENERATED_HASH"
}
Successful response
{
"payoutId": "00000000-0000-0000-0000-000000000000",
"payoutStatus": {
"status": 1,
"subStatus": 201,
"errorMessage": ""
}
}
status is 1, Payout Received: the request is queued, not paid. Only 5 means the payout
completed. The full list is in Transaction and settlement statuses.
Store the payoutId, you will use it to check payout status in Step 6.
Important
A rejected payout is also an HTTP 200. A failed hash, a reference that is
too long, an amount above your float balance: all of them come back with status 200, an
empty payoutId, and the reason in payoutStatus.errorMessage. Treat a payout as accepted
only when payoutId is populated and errorMessage is empty. A status code on its own
tells you the request arrived, not that the payout was taken.
Rejected response
{
"payoutId": "",
"payoutStatus": {
"status": 0,
"subStatus": 0,
"errorMessage": "The HashCheck value has failed"
}
}
The API returns a status code other than 200 only when the request does not reach the payouts
service at all: 400 when a required header is missing or the body is malformed, 403 when
the SiteCode and ApiKey pair is not accepted, and 500 when something fails on the Ozow
side. Those carry a message field rather than a payoutStatus.
Step 5: Implement the verification webhook
Ozow calls your verification webhook to verify each payout request and obtain the AES decryption key for the encrypted account number. Your webhook must respond exactly as specified in this guide, any deviation from the required response format will cause verification to fail and the payout will be cancelled.
Your webhook must:
- Validate the incoming request using bearer tokenBearer token An access token sent in the
Authorizationheader asAuthorization: Bearer <token>. Anyone holding the token can use it, which is why it belongs on your server and never in a browser or a mobile app.RFC 6750 authentication - Verify the webhook hash
- Validate that the payout details match a payout your system actually initiated
- Return the decryption key and verification status in the exact format specified below
Webhook authentication
Ozow passes your access token in the AccessToken header of the verification request. Validate this
token before processing.
Verifying the webhook hash
Generate a SHA512 hash using the fields below in this exact order:
| Position | Field |
|---|---|
| 1 | Payout |
| 2 | Site |
| 3 | Amount in cents |
| 4 | Merchant |
| 5 | Customer |
| 6 | Is |
| 7 | Notify |
| 8 | Bank |
| 9 | Account (encrypted) |
| 10 | Branch |
| 11 | Api |
Compare your generated hash to the HashCheck field in the verification request.
Security
Always validate the access token AND the hash before returning the decryption
key. Also confirm that the PayoutId matches a payout your system initiated: do not return the
decryption key based on token or hash checks alone.
Verification response
Your webhook must return HTTP 200 with the following JSON:
{
"payoutId": "00000000-0000-0000-0000-000000000000",
"isVerified": true,
"accountNumberDecryptionKey": "YOUR_ENCRYPTION_KEY",
"reason": ""
}
If verification fails, set isVerified to false and provide a reason. Ozow will send a payout
cancelled notification if it cannot decrypt the account number with the key provided.
Step 6: Handle the notification response
Ozow posts a notification to your NotifyUrl when the payout completes.
Verifying the notification hash
- Concatenate the notification fields (excluding
hashCheck) in the order below - Append your API key
- Convert to lowercase
- Generate a SHA512 hash
- Compare to the
hashCheckvalue in the notification
Notification hash field order
| Position | Field |
|---|---|
| 1 | payout |
| 2 | site |
| 3 | merchant |
| 4 | customer |
| 5 | payout, as its integer value |
| 6 | payout, as its integer value |
| 7 | Your API key |
Important
The two status fields go into the hash as integers, not as their
names. Concatenate 3, not Complete.
Important
Always verify the notification hash before updating your records. Never update your ledger without first verifying the hash.
Note
Ozow may occasionally send duplicate notifications for the same payout. Handle these idempotentlyIdempotency A request is idempotent when sending it twice has the same effect as sending it once. It matters most where a retry after a timeout could otherwise take a payment twice.IETF draft, receiving the same notification twice must not result in double-crediting or double-debiting.
Step 7: Confirm payout status via API (recommended)
In addition to receiving notifications, implement the status check endpoint as a backup mechanism. Webhook delivery can be affected by network issues, endpoint downtime, or platform instability.
Your system must treat the initial payout request as pending and, if no notification is received
within your defined SLA (typically 2-5 minutes): call the status endpoint using the payoutId
returned in Step 4.
By payout ID
GET https://payoutsapi.ozow.com/v1/getpayout?payoutId={payoutId}
SiteCode: YOUR_SITE_CODE
ApiKey: YOUR_API_KEY
By merchant reference
POST https://payoutsapi.ozow.com/v1/getpayoutbyreference
SiteCode: YOUR_SITE_CODE
ApiKey: YOUR_API_KEY
{
"pageSize": 10,
"pageIndex": 1,
"searchFields": [1],
"searchString": "YOUR_MERCHANT_REFERENCE"
}
Response example
{
"id": "00000000-0000-0000-0000-000000000000",
"amount": 100.00,
"merchantReference": "ORDER-001",
"customerBankReference": "ABC123",
"siteCode": "YOUR_SITE_CODE",
"isRtc": false,
"payoutStatus": {
"status": 5,
"subStatus": 0,
"errorMessage": "Complete"
}
}
Payout outcome flow
Next steps
- Complete mandatory payout testing: see Payout test cases
- Review the Building a secure integration checklist before going live
- See the Payouts API reference for the full technical specification
In the API reference
5 entries
Last updated