The ledger and settlement
Where your collected funds accrue, when the balance moves, what ledger enforcement changes, and how settlement accounts protect a withdrawal.
Money you collect does not sit in a customer's wallet or a shared pot - it accrues to your Integrator ledger. That balance is what a withdrawal draws down, and optionally what a payout is checked against.
One balance per currency, per environment
A ledger balance is keyed on (integrator, currency, environment). Your TEST balance and your
LIVE balance are entirely separate, and GHS and any other currency you transact in are separate
again. A mocked collection made with a test key can never fund a live payout.
GET /integrators/{integratorId}/ledger
GET /integrators/{integratorId}/ledger/entries?pg=1&ipp=50&from=2026-08-01T00:00:00.000Z
Both accept either a signed API key or a Bearer session.
What moves the balance
The entry log is immutable - balances are never edited in place, only moved by an entry:
| Entry type | When |
|---|---|
COLLECTION_CREDIT |
A collection is confirmed successful |
PAYOUT_DEBIT |
A payout or withdrawal is dispatched |
PAYOUT_REVERSAL |
A dispatch that was already debited comes back failed |
ADJUSTMENT |
A manual correction by PayDirect, always with a recorded reason |
COMMISSION_CREDIT |
Your own commission on a transaction that succeeded |
COMMISSION_REVERSAL |
A commission taken back because its transaction was later confirmed failed |
A LIVE COMMISSION_CREDIT is held for 24 hours before it can be paid out or withdrawn; ledger
rows report heldCommission and availableBalance (see commissions).
A COMMISSION_REVERSAL is never refused for lack of balance: if the commission was already
withdrawn, the balance goes below zero, and payouts and withdrawals are refused until it is
covered. Filter the entry log with type, from and to, e.g.
/ledger/entries?type=COMMISSION_CREDIT&from=2026-09-01T00:00:00.000Z.
Warning
A collection credits your ledger only once funding is confirmed - never while it is still
PENDING. For a synchronous rail that is at the moment of the response; for an asynchronous wallet it is at reconciliation, or when the provider's callback resolves it. APENDINGcollection is not money you have.
Because reconciliation is the credit point for async collections, we guard against double-crediting: reconciling an already-resolved transaction is a no-op on the balance. Calling reconcile twice is safe.
Reversal
If a payout is debited and the dispatch then fails, a PAYOUT_REVERSAL entry restores the
balance. This holds on both paths - an immediate dispatch failure and one discovered later during
reconciliation, so a FAILED payout does not reduce your balance.
Balance checks
Payouts and withdrawals are paid from your ledger balance. The balance is debited before
dispatch, and an insufficient balance is a clean 422: nothing dispatched, no transaction row
created. You cannot pay out more than you have collected.
Check your balance before a large batch, rather than discovering the shortfall one rejection at a time:
GET /integrators/{integratorId}/ledger
Settlement accounts
A settlement account is the pre-registered destination a withdrawal goes to. One active account
per (currency, environment).
| Field | Notes |
|---|---|
currency, environment |
TEST or LIVE |
accountType |
BANK_ACCOUNT (requires swiftCode) or E_WALLET (requires ewalletType) |
accountName, accountNumber, bankName |
The destination itself |
GET /integrators/{integratorId}/settlement-accounts
Caution
Registering and changing a settlement account is done by PayDirect, never self-service - and that is the entire security property.
POST /transactions/withdrawtakes an amount and a currency and no destination fields at all. A stolen key cannot redirect your balance anywhere, because the destination is not something a request can express.
This is why WITHDRAWALS_WRITE and PAYOUTS_WRITE are separate scopes. A key that can only
withdraw can only ever move money to an account you already vetted with us.
Reading your position
# Current balances, all currencies and environments
GET /integrators/{id}/ledger
# Every movement in a window, for reconciliation against your own books
GET /integrators/{id}/ledger/entries?from=2026-08-01T00:00:00.000Z&to=2026-08-31T23:59:59.999Z
# Your Integrator's configuration, including whether enforcement is on
GET /integrators/{id}/info
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);
}
}
Read your balances:
cURL
paydirect GET "/integrators/$INTEGRATOR_ID/ledger"
Node.js
const { status, body } = await payDirect.get(`/integrators/${integratorId}/ledger`);
Python
status, body = get(f"/integrators/{integrator_id}/ledger")
PHP
<?php
$result = (new PayDirect())->get("/integrators/{$integratorId}/ledger");
Java
HttpResponse<String> response = new PayDirect().get("/integrators/" + integratorId + "/ledger");
C#
var response = await new PayDirect().GetAsync($"/integrators/{integratorId}/ledger");
And every movement in a window, for reconciliation against your own books:
cURL
paydirect GET "/integrators/$INTEGRATOR_ID/ledger/entries?from=2026-08-01T00:00:00.000Z&to=2026-08-31T23:59:59.999Z"
Node.js
const { status, body } = await payDirect.get(`/integrators/${integratorId}/ledger/entries?from=2026-08-01T00:00:00.000Z&to=2026-08-31T23:59:59.999Z`);
Python
status, body = get(f"/integrators/{integrator_id}/ledger/entries?from=2026-08-01T00:00:00.000Z&to=2026-08-31T23:59:59.999Z")
PHP
<?php
$result = (new PayDirect())->get("/integrators/{$integratorId}/ledger/entries?from=2026-08-01T00:00:00.000Z&to=2026-08-31T23:59:59.999Z");
Java
HttpResponse<String> response = new PayDirect().get("/integrators/" + integratorId + "/ledger/entries?from=2026-08-01T00:00:00.000Z&to=2026-08-31T23:59:59.999Z");
C#
var response = await new PayDirect().GetAsync($"/integrators/{integratorId}/ledger/entries?from=2026-08-01T00:00:00.000Z&to=2026-08-31T23:59:59.999Z");
Reconcile the entry log against your own ledger on a schedule - daily is typical. Entries are immutable and each references the transaction that caused it, so any disagreement resolves to a specific transaction id rather than a mystery difference.
Sharing collections with others
If part of what you collect belongs to vendors or partners, you can split a collection so their share never lands on your ledger at all: it is credited to their own subaccount balance and paid out to them on its own schedule. See Split payments and subaccounts.
Next
Split payments and subaccounts - share a collection with the people you work with, and have their share paid out automatically.