Skip to content
PayDirect Docs

Docs › Sending money

Payouts

Disburse to a bank account or mobile money wallet, resolve an account name first, and reconcile the outcome.

A payout moves money out to a recipient you name. Use it to pay vendors, suppliers, drivers, sellers - anyone who is not you. (To move your own accrued balance to your own account, use a withdrawal instead; it is deliberately a different, safer endpoint.)

Endpoint: POST /transactions/disburse Alias: POST /transactions/payout-credit-transfer - same handler, same schema, same guards. Scope: PAYOUTS_WRITE. Transaction type: PAYOUT.

A payout, end to end. The credit enquiry is the only step that catches a mistyped account number.

Resolve the account name first (bank transfers)

For a bank destination, confirm who owns the account before sending:

Show the PayDirect client (defined once in Authentication)

PayDirect has no SDK, so write this once. Every example in these guides calls the client defined here, which means signing lives in exactly one place - and this block is itself a single shared file, so if it changes, it changes on every page at once.

Each version reads PAYDIRECT_HOST, PAYDIRECT_PUBLIC_KEY, PAYDIRECT_RAW_KEY and PAYDIRECT_SECRET_KEY from the environment, signs every request, and sends an Idempotency-Key on writes (see Idempotency).

cURL

# paydirect.sh - source this, then call `paydirect POST /transactions/collect "$BODY"`
# Works for GET, POST, PATCH and DELETE; a body is optional.
paydirect() {
  local method="$1" path="$2" body="${3:-}"
  local ts sig
  ts=$(date +%s000)                                   # milliseconds, not seconds
  sig=$(printf '%s:%s' "$PAYDIRECT_RAW_KEY" "$ts" \
    | openssl dgst -sha256 -hmac "$PAYDIRECT_SECRET_KEY" -hex \
    | awk '{print $2}')

  curl -sS -X "$method" "$PAYDIRECT_HOST$path" \
    -H "Content-Type: application/json" \
    -H "x-api-key-id: $PAYDIRECT_PUBLIC_KEY" \
    -H "x-api-timestamp: $ts" \
    -H "x-api-signature: $sig" \
    ${body:+-H "Idempotency-Key: $(uuidgen)"} \
    ${body:+-d "$body"}
}

Node.js

// paydirect.js - Node 18+, no dependencies
const crypto = require('crypto');

function signedHeaders() {
  const timestamp = Date.now().toString(); // milliseconds
  const signature = crypto
    .createHmac('sha256', process.env.PAYDIRECT_SECRET_KEY)
    .update(`${process.env.PAYDIRECT_RAW_KEY}:${timestamp}`)
    .digest('hex');

  return {
    'Content-Type': 'application/json',
    'x-api-key-id': process.env.PAYDIRECT_PUBLIC_KEY,
    'x-api-timestamp': timestamp,
    'x-api-signature': signature,
  };
}

async function request(method, path, body, idempotencyKey) {
  const headers = signedHeaders();
  if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;

  const response = await fetch(`${process.env.PAYDIRECT_HOST}${path}`, {
    method,
    headers,
    body: body ? JSON.stringify(body) : undefined,
  });
  return { status: response.status, body: await response.json() };
}

const payDirect = {
  get: path => request('GET', path),
  post: (path, body, idempotencyKey) => request('POST', path, body, idempotencyKey),
  patch: (path, body) => request('PATCH', path, body),
  del: path => request('DELETE', path),
};

module.exports = { payDirect };

Python

# paydirect.py - requires `requests`
import hashlib, hmac, os, time, uuid
import requests

HOST = os.environ["PAYDIRECT_HOST"]

def _signed_headers() -> dict:
    timestamp = str(int(time.time() * 1000))  # milliseconds, not seconds
    signature = hmac.new(
        os.environ["PAYDIRECT_SECRET_KEY"].encode(),
        f'{os.environ["PAYDIRECT_RAW_KEY"]}:{timestamp}'.encode(),
        hashlib.sha256,
    ).hexdigest()

    return {
        "Content-Type": "application/json",
        "x-api-key-id": os.environ["PAYDIRECT_PUBLIC_KEY"],
        "x-api-timestamp": timestamp,
        "x-api-signature": signature,
    }

def request(method: str, path: str, body: dict | None = None, idempotency_key: str | None = None):
    headers = _signed_headers()
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key

    response = requests.request(method, f"{HOST}{path}", json=body, headers=headers, timeout=30)
    return response.status_code, response.json()

def get(path: str):
    return request("GET", path)

def post(path: str, body: dict, idempotency_key: str | None = None):
    return request("POST", path, body, idempotency_key or str(uuid.uuid4()))

def patch(path: str, body: dict):
    return request("PATCH", path, body)

def delete(path: str):
    return request("DELETE", path)

PHP

<?php
// PayDirect.php - requires ext-curl, no packages

final class PayDirect
{
    public function get(string $path): array
    {
        return $this->request('GET', $path);
    }

    public function post(string $path, array $body, ?string $idempotencyKey = null): array
    {
        return $this->request('POST', $path, $body, $idempotencyKey ?? bin2hex(random_bytes(16)));
    }

    public function patch(string $path, array $body): array
    {
        return $this->request('PATCH', $path, $body);
    }

    public function delete(string $path): array
    {
        return $this->request('DELETE', $path);
    }

    private function signedHeaders(): array
    {
        $timestamp = (string) round(microtime(true) * 1000); // milliseconds
        $signature = hash_hmac(
            'sha256',
            getenv('PAYDIRECT_RAW_KEY') . ':' . $timestamp,
            getenv('PAYDIRECT_SECRET_KEY')
        );

        return [
            'Content-Type: application/json',
            'x-api-key-id: ' . getenv('PAYDIRECT_PUBLIC_KEY'),
            'x-api-timestamp: ' . $timestamp,
            'x-api-signature: ' . $signature,
        ];
    }

    private function request(string $method, string $path, ?array $body = null, ?string $key = null): array
    {
        $headers = $this->signedHeaders();
        if ($key !== null) {
            $headers[] = 'Idempotency-Key: ' . $key;
        }

        $ch = curl_init(getenv('PAYDIRECT_HOST') . $path);
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST  => $method,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER     => $headers,
            CURLOPT_TIMEOUT        => 30,
        ]);
        if ($body !== null) {
            curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
        }

        $raw    = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);

        return ['status' => $status, 'body' => json_decode($raw, true)];
    }
}

Java

// PayDirect.java - JDK 11+, no dependencies
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.UUID;

public final class PayDirect {
    private static final String HOST = System.getenv("PAYDIRECT_HOST");
    private final HttpClient http = HttpClient.newHttpClient();

    public HttpResponse<String> get(String path) throws Exception {
        return request("GET", path, null, null);
    }

    public HttpResponse<String> post(String path, String jsonBody) throws Exception {
        return request("POST", path, jsonBody, UUID.randomUUID().toString());
    }

    public HttpResponse<String> patch(String path, String jsonBody) throws Exception {
        return request("PATCH", path, jsonBody, null);
    }

    public HttpResponse<String> delete(String path) throws Exception {
        return request("DELETE", path, null, null);
    }

    private static String hmacHex(String message, String secret) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        StringBuilder hex = new StringBuilder();
        for (byte b : mac.doFinal(message.getBytes(StandardCharsets.UTF_8))) {
            hex.append(String.format("%02x", b)); // lowercase hex
        }
        return hex.toString();
    }

    private HttpResponse<String> request(String method, String path, String body, String key)
            throws Exception {
        String timestamp = String.valueOf(System.currentTimeMillis()); // milliseconds
        String signature = hmacHex(System.getenv("PAYDIRECT_RAW_KEY") + ":" + timestamp,
                                   System.getenv("PAYDIRECT_SECRET_KEY"));

        HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(HOST + path))
                .timeout(Duration.ofSeconds(30))
                .header("Content-Type", "application/json")
                .header("x-api-key-id", System.getenv("PAYDIRECT_PUBLIC_KEY"))
                .header("x-api-timestamp", timestamp)
                .header("x-api-signature", signature)
                .method(method, body == null
                        ? HttpRequest.BodyPublishers.noBody()
                        : HttpRequest.BodyPublishers.ofString(body));

        if (key != null) {
            builder.header("Idempotency-Key", key);
        }
        return http.send(builder.build(), HttpResponse.BodyHandlers.ofString());
    }
}

C#

// PayDirect.cs - .NET 6+, no packages
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;

public sealed class PayDirect
{
    private static readonly HttpClient Http = new();
    private static readonly string Host = Environment.GetEnvironmentVariable("PAYDIRECT_HOST")!;

    public Task<HttpResponseMessage> GetAsync(string path) =>
        SendAsync(HttpMethod.Get, path, null, null);

    public Task<HttpResponseMessage> PostAsync(string path, object body, string? idempotencyKey = null) =>
        SendAsync(HttpMethod.Post, path, body, idempotencyKey ?? Guid.NewGuid().ToString());

    public Task<HttpResponseMessage> PatchAsync(string path, object body) =>
        SendAsync(HttpMethod.Patch, path, body, null);

    public Task<HttpResponseMessage> DeleteAsync(string path) =>
        SendAsync(HttpMethod.Delete, path, null, null);

    private static string HmacHex(string message, string secret)
    {
        using var mac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
        var hash = mac.ComputeHash(Encoding.UTF8.GetBytes(message));
        return Convert.ToHexString(hash).ToLowerInvariant(); // lowercase hex
    }

    private static async Task<HttpResponseMessage> SendAsync(
        HttpMethod method, string path, object? body, string? idempotencyKey)
    {
        // Milliseconds since the epoch, not seconds.
        var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
        var signature = HmacHex(
            $"{Environment.GetEnvironmentVariable("PAYDIRECT_RAW_KEY")}:{timestamp}",
            Environment.GetEnvironmentVariable("PAYDIRECT_SECRET_KEY")!);

        using var request = new HttpRequestMessage(method, Host + path);
        request.Headers.Add("x-api-key-id", Environment.GetEnvironmentVariable("PAYDIRECT_PUBLIC_KEY"));
        request.Headers.Add("x-api-timestamp", timestamp);
        request.Headers.Add("x-api-signature", signature);
        if (idempotencyKey is not null)
        {
            request.Headers.Add("Idempotency-Key", idempotencyKey);
        }
        if (body is not null)
        {
            request.Content = JsonContent.Create(body);
        }

        return await Http.SendAsync(request);
    }
}

cURL

paydirect POST /transactions/payout-credit-enquiry \
  '{ "recipientAccountNumber": "1234567890", "recipientSwiftCode": "300591" }'

Node.js

const { body } = await payDirect.post('/transactions/payout-credit-enquiry', {
  recipientAccountNumber: '1234567890',
  recipientSwiftCode: '300591', // the GHIPSS routing code, from GET /banks/list
});

Python

status, body = post("/transactions/payout-credit-enquiry", {
    "recipientAccountNumber": "1234567890",
    "recipientSwiftCode": "300591",  # the GHIPSS routing code, from GET /banks/list
})

PHP

<?php
$result = (new PayDirect())->post('/transactions/payout-credit-enquiry', [
    'recipientAccountNumber' => '1234567890',
    'recipientSwiftCode'     => '300591', // the GHIPSS routing code, from GET /banks/list
]);

Java

HttpResponse<String> response = new PayDirect().post(
        "/transactions/payout-credit-enquiry",
        """
        { "recipientAccountNumber": "1234567890", "recipientSwiftCode": "300591" }
        """);

C#

var response = await new PayDirect().PostAsync("/transactions/payout-credit-enquiry", new
{
    recipientAccountNumber = "1234567890",
    recipientSwiftCode     = "300591", // the GHIPSS routing code, from GET /banks/list
});

It returns the account name the bank holds. Show it to whoever authorises the payment, and send that exact name as recipientAccountName.

Warning

A bank transfer to a valid account number with the wrong name still arrives - at the wrong person, irreversibly. The credit enquiry costs one call and is the only check that catches a mistyped account number before the money is gone.

Request

text
POST /transactions/disburse
Idempotency-Key: <a key you persisted>
Field Required Notes
recipientAccountType yes BANK_ACCOUNT or E_WALLET - this selects the rail
recipientAccountNumber yes Bank account number, or mobile money number
amountToSend yes Decimal, major units. The recipient receives this in full under the default SENDER charge bearer.
sendingCurrency / receivingCurrency yes e.g. "GHS"
description no Shown on statements
invoiceOrAccountNumber no Your reference. A UUID is generated if you omit it.
senderEmail no Notified of the outcome
recipientEmail no Notified on success only

BANK_ACCOUNT also requires:

Field Notes
recipientAccountName Must match the credit-enquiry result
recipientSwiftCode The GHIPSS routing code of the destination bank - see the note below
recipientBankName Optional, display only

E_WALLET also requires:

Field Notes
recipientEwalletType MTN, TELECEL, AIRTEL_TIGO or ZEE_PAY

Note

Despite its name, recipientSwiftCode carries the GHIPSS routing code (e.g. 300591), not an ISO 9362 SWIFT/BIC. Get it from GET /banks/list - do not look up a SWIFT code elsewhere and send that.

cURL

paydirect POST /transactions/disburse '{
  "recipientAccountType": "E_WALLET",
  "recipientEwalletType": "MTN",
  "recipientAccountNumber": "0244000000",
  "amountToSend": 100.00,
  "sendingCurrency": "GHS",
  "receivingCurrency": "GHS",
  "description": "Vendor payout - August",
  "invoiceOrAccountNumber": "payout-aug-0417",
  "recipientEmail": "vendor@example.com"
}'

Node.js

const { status, body } = await payDirect.post(
  '/transactions/disburse',
  {
    recipientAccountType: 'E_WALLET',
    recipientEwalletType: 'MTN',
    recipientAccountNumber: '0244000000',
    amountToSend: 100.0,
    sendingCurrency: 'GHS',
    receivingCurrency: 'GHS',
    description: 'Vendor payout - August',
    invoiceOrAccountNumber: 'payout-aug-0417',
    recipientEmail: 'vendor@example.com',
  },
  `payout:${invoice.id}`, // derived from the invoice, persisted before the first attempt
);

if (body.data?.status === 'FAILED') await markPayoutFailed(invoice.id, body.data.id);

Python

status, body = post(
    "/transactions/disburse",
    {
        "recipientAccountType": "E_WALLET",
        "recipientEwalletType": "MTN",
        "recipientAccountNumber": "0244000000",
        "amountToSend": 100.00,
        "sendingCurrency": "GHS",
        "receivingCurrency": "GHS",
        "description": "Vendor payout - August",
        "invoiceOrAccountNumber": "payout-aug-0417",
        "recipientEmail": "vendor@example.com",
    },
    idempotency_key=f"payout:{invoice.id}",  # persisted before the first attempt
)

if body["data"]["status"] == "FAILED":
    mark_payout_failed(invoice.id, body["data"]["id"])

PHP

<?php
$result = (new PayDirect())->post('/transactions/disburse', [
    'recipientAccountType'   => 'E_WALLET',
    'recipientEwalletType'   => 'MTN',
    'recipientAccountNumber' => '0244000000',
    'amountToSend'           => 100.00,
    'sendingCurrency'        => 'GHS',
    'receivingCurrency'      => 'GHS',
    'description'            => 'Vendor payout - August',
    'invoiceOrAccountNumber' => 'payout-aug-0417',
    'recipientEmail'         => 'vendor@example.com',
], "payout:{$invoice->id}"); // persisted before the first attempt

if ($result['body']['data']['status'] === 'FAILED') {
    $this->markPayoutFailed($invoice->id, $result['body']['data']['id']);
}

Java

String payload = """
    {
      "recipientAccountType": "E_WALLET",
      "recipientEwalletType": "MTN",
      "recipientAccountNumber": "0244000000",
      "amountToSend": 100.00,
      "sendingCurrency": "GHS",
      "receivingCurrency": "GHS",
      "description": "Vendor payout - August",
      "invoiceOrAccountNumber": "payout-aug-0417",
      "recipientEmail": "vendor@example.com"
    }
    """;

HttpResponse<String> response = new PayDirect().post("/transactions/disburse", payload);
// Read data.status - never infer success from the HTTP code alone.

C#

var response = await new PayDirect().PostAsync("/transactions/disburse", new
{
    recipientAccountType   = "E_WALLET",
    recipientEwalletType   = "MTN",
    recipientAccountNumber = "0244000000",
    amountToSend           = 100.00m, // decimal, never double
    sendingCurrency        = "GHS",
    receivingCurrency      = "GHS",
    description            = "Vendor payout - August",
    invoiceOrAccountNumber = "payout-aug-0417",
    recipientEmail         = "vendor@example.com",
}, idempotencyKey: $"payout:{invoice.Id}"); // persisted before the first attempt

Response

201 with the transaction. Persist data.id - it is the only key you can re-query with.

data.status Means
SUCCESS Confirmed by the rail. Terminal.
PENDING In flight. Wait for a webhook or reconcile.
FAILED Rejected or confirmed failed. The ledger debit is returned to your balance. Terminal.

Many payouts resolve in the same request. Some wallet networks confirm later and return PENDING. Handle both for every destination.

Warning

A 200/201 with "status": "error" in the envelope means the payout was dispatched and definitively failed. That is a resolved outcome, not an ambiguous one - but it is not a success. Always read data.status.

What you are debited

Under the default SENDER charge bearer, your ledger is debited amountToSend + charges and the recipient receives amountToSend intact. Read totalDebit for the real outflow. See Amounts and charges.

Balance

Your ledger balance is debited before dispatch. An insufficient balance is a clean 422 with nothing dispatched and no transaction created. A failed dispatch writes a reversal and restores the balance. See The ledger.

Resolving a PENDING payout

Webhooks. PAYOUT_SUCCEEDED / PAYOUT_FAILED.

Reconcile. POST /transactions/payout-credit-transfer/{id}/reconcile, or the identical POST /transactions/disburse/{id}/reconcile.

Read. GET /transactions/find/{id}, or GET /transactions/{id}/journey-snapshot for the step-by-step history when something looks wrong.

Note

SUCCESS and FAILED are terminal statuses for PAYOUT: reconciliation does not change them once resolved, and you can book a terminal payout as final. In the rare case that a bank or network recalls a payment afterwards, it is handled with you through support, not by silently changing the status.

Retrying a failed payout

A FAILED payout moved no money, so a retry is a genuinely new payment attempt. Submit it with a new Idempotency-Key and a new reference - reusing the original key would just replay the failure response.

Fix the cause first. A payout that failed because of a wrong wallet number will fail again.

Next

Withdrawals - moving your own balance to your own account.