Skip to content
PayDirect Docs

Docs › Core concepts

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.

text
GET /integrators/{integratorId}/ledger
text
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

Money in credits only on confirmed funding; a failed dispatch is reversed.

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. A PENDING collection 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:

text
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
text
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/withdraw takes 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

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