# 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.

Source: https://hub.ozow.com/integration-methods/apis/payout/send-a-payout/

This guide walks you through integrating Ozow payouts 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 payins 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](mailto: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](https://hub.ozow.com/integration-methods/testing/payout-test-cases.md) for the full
> requirements.

Once approved, ensure you have the following in place:

- Your Payout API key and site code, 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 webhook endpoint is set up and publicly accessible via HTTPS
- Your notification URL is set up and publicly accessible via HTTPS
- Your Ozow float is funded, Ozow uses your float balance to process payouts. See [Float top-up
  guide](https://hub.ozow.com/payment-products/settlements-and-float/float-top-up.md) to load funds into your
  float before going live.

## How payout integration works

```mermaid
sequenceDiagram
    participant M as Your system
    participant O as Payouts API
    participant V as Your verification webhook
    participant B as Recipient bank

    M->>O: GET /getavailablebanks (optional)
    O-->>M: Returns available banks and RTC status
    M->>M: Encrypt account number (AES-256-CBC)
    M->>M: Generate hash check
    M->>O: POST /requestpayout
    O-->>M: Returns payoutId and initial status
    O->>V: POST verification request
    V->>V: Verify hash, validate payout details
    V-->>O: Returns IsVerified + decryption key
    O->>B: Processes payout to recipient bank
    O-->>M: Sends notification to NotifyUrl
    M->>M: Verifies notification hash
    M->>O: GET /getpayout (recommended backup)
```

## Environments

| Environment | Base URL | Mock base URL | Dashboard |
|---|---|---|---|
| Production | `https://payoutsapi.ozow.com/v1` | `https://payoutsapi.ozow.com/mock/v1` | [dash.ozow.com](https://dash.ozow.com) |
| Staging | `https://stagingpayoutsapi.ozow.com/v1` | `https://stagingpayoutsapi.ozow.com/mock/v1` | [stagingdash.ozow.com](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 |
|---|---|
| `SiteCode` | Your Ozow site code |
| `ApiKey` | 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 clearing
(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.

```endpoint
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**

```json
[
  {
    "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 SHA512 hash of the concatenation of:

1. Merchant reference
2. Amount in cents (e.g. `10000` for R100.00)
3. 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.

**C#**

```csharp
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**

```php
<?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;
?>
```

**JavaScript**

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

**Python**

```python
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 | `siteCode` |
| 2 | `amount` in cents, so `10000` for R100.00 |
| 3 | `merchantReference` |
| 4 | `customerBankReference` |
| 5 | `isRtc` |
| 6 | `notifyUrl` |
| 7 | `bankingDetails.bankGroupId` |
| 8 | `bankingDetails.accountNumber`, encrypted |
| 9 | `bankingDetails.branchCode` |
| 10 | Your API key |

**Steps**

1. Concatenate the fields above in order
2. Convert the entire string to lowercase
3. 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.

**C#**

```csharp
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**

```php
<?php
$inputString = strtolower(
    $siteCode .
        round($amount * 100) .
        $merchantReference .
        $customerBankReference .
        "false" .
        $notifyUrl .
        $bankGroupId .
        $accountNumber .
        $branchCode .
        $apiKey,
);
$hashCheck = hash("sha512", $inputString);
echo "HashCheck: " . $hashCheck;
?>
```

**JavaScript**

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

**Python**

```python
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

```endpoint
POST https://payoutsapi.ozow.com/v1/requestpayout
SiteCode: YOUR_SITE_CODE
ApiKey: YOUR_API_KEY
Content-Type: application/json
```

**Request example**

```json
{
  "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**

```json
{
  "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](https://hub.ozow.com/integration-methods/statuses.md#payout-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**

```json
{
  "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:

1. Validate the incoming request using bearer token authentication
2. Verify the webhook hash
3. Validate that the payout details match a payout your system actually initiated
4. 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 | `PayoutId` |
| 2 | `SiteCode` |
| 3 | `Amount` in cents |
| 4 | `MerchantReference` |
| 5 | `CustomerBankReference` |
| 6 | `IsRtc` |
| 7 | `NotifyUrl` |
| 8 | `BankGroupId` |
| 9 | `AccountNumber` (encrypted) |
| 10 | `BranchCode` |
| 11 | `ApiKey` |

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:

```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**

1. Concatenate the notification fields (excluding `hashCheck`) in the order below
2. Append your API key
3. Convert to lowercase
4. Generate a SHA512 hash
5. Compare to the `hashCheck` value in the notification

**Notification hash field order**

| Position | Field |
|---|---|
| 1 | `payoutId` |
| 2 | `siteCode` |
| 3 | `merchantReference` |
| 4 | `customerMerchantReference` |
| 5 | `payoutStatus.status`, as its integer value |
| 6 | `payoutStatus.subStatus`, 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
> idempotently, 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**

```endpoint
GET https://payoutsapi.ozow.com/v1/getpayout?payoutId={payoutId}
SiteCode: YOUR_SITE_CODE
ApiKey: YOUR_API_KEY
```

**By merchant reference**

```endpoint
POST https://payoutsapi.ozow.com/v1/getpayoutbyreference
SiteCode: YOUR_SITE_CODE
ApiKey: YOUR_API_KEY
```

```json
{
  "pageSize": 10,
  "pageIndex": 1,
  "searchFields": [1],
  "searchString": "YOUR_MERCHANT_REFERENCE"
}
```

**Response example**

```json
{
  "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

```mermaid
flowchart LR
    A[Submit payout request] --> B{Receive notification}
    B -->|Within SLA| C{Verify notification hash}
    B -->|No notification - SLA exceeded| D[Call getpayout API]
    C -->|Invalid| E[Log and alert - do not process]
    C -->|Valid| F{Check payout status}
    D --> F
    F -->|Complete| G[Update ledger and confirm payout]
    F -->|Cancelled| H[Investigate and resubmit if appropriate]
    F -->|Failed| I[Check sub-status and take action]
    F -->|Pending| J[Continue polling with backoff]
```

---

## Next steps

- Complete mandatory payout testing: see [Payout test cases](https://hub.ozow.com/integration-methods/testing/payout-test-cases.md)
- Review the [Building a secure
  integration](https://hub.ozow.com/getting-started/building-a-secure-integration.md) checklist before going
  live
- See the [Payouts API reference](https://hub.ozow.com/api-reference/payouts-api.md) for the full technical specification