Skip to content
PayDirect Docs

Docs › Core concepts

Amounts and charges

Which amount field means what, who bears the platform charge, and how to quote a total before you charge a customer.

Four amount fields travel together on a transaction. Showing the wrong one to a customer, or reconciling against the wrong one, is the most common accounting mistake in a payments integration - so it is worth ten minutes now.

The four fields

Field Meaning
amountToSend The principal - what the transaction is nominally for. Never changes meaning.
amountReceived What the beneficiary actually receives.
totalDebit What was actually debited from the payer (or from your ledger balance).
charges The platform charge, broken out.

The invariant that always holds:

text
amountReceived = totalDebit − charges

Note

totalDebit is null on transactions created before charge-bearer support existed. Treat null as equal to amountToSend.

If you have set an Integrator commission, a fifth field, commission, carries it. It is never part of charges, it follows the charge's bearer, and the invariant becomes amountReceived = totalDebit − charges − commission. commission is 0 whenever none applied.

Who bears the charge

Each charge schedule carries a chargeBearer:

chargeBearer Payer is debited Beneficiary receives
SENDER (default) amount + charge amount
RECIPIENT amount amount − charge

The default is SENDER: the beneficiary receives the full principal, and the charge is added on top of what is debited. A GHS 10.00 transfer with a GHS 0.03 charge debits GHS 10.03 and delivers GHS 10.00 intact.

For a payout or withdrawal there is no separate paying customer, so you are the sender: your ledger balance is debited principal + charge, and the destination receives the principal.

chargeBearer can be set to RECIPIENT per integrator on request - ask us if that fits your model better.

Which movements are charged to the sender

SEND_MONEY, PAY_BILL, PAYOUT (/disburse), WITHDRAWAL and MONEY_REQUEST_DISBURSEMENT.

COLLECTION is unchanged: direct collections and hosted checkout charge the payer exactly the amount you requested and credit you net.

Quote before you charge

POST /transactions/quote-charge returns the exact numbers for a movement before you commit to it. Requires the TRANSACTIONS_READ scope.

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/quote-charge \
  '{ "transactionType": "SEND_MONEY", "amount": 500, "currency": "GHS" }'

Node.js

const { body } = await payDirect.post('/transactions/quote-charge', {
  transactionType: 'SEND_MONEY',
  amount: 500,
  currency: 'GHS',
});

showCustomer(body.data.totalDebit); // authoritative - never compute this yourself

Python

status, body = post("/transactions/quote-charge", {
    "transactionType": "SEND_MONEY",
    "amount": 500,
    "currency": "GHS",
})

show_customer(body["data"]["totalDebit"])  # authoritative - never compute this yourself

PHP

<?php
$result = (new PayDirect())->post('/transactions/quote-charge', [
    'transactionType' => 'SEND_MONEY',
    'amount'          => 500,
    'currency'        => 'GHS',
]);

showCustomer($result['body']['data']['totalDebit']); // authoritative

Java

HttpResponse<String> response = new PayDirect().post(
        "/transactions/quote-charge",
        """
        { "transactionType": "SEND_MONEY", "amount": 500, "currency": "GHS" }
        """);
// Read data.totalDebit - it is authoritative for both charge-bearer modes.

C#

var response = await new PayDirect().PostAsync("/transactions/quote-charge", new
{
    transactionType = "SEND_MONEY",
    amount          = 500m, // decimal, never double
    currency        = "GHS",
});
// Read data.totalDebit - it is authoritative for both charge-bearer modes.
json
{
  "amount": 500,
  "charge": 5,
  "chargeBearer": "SENDER",
  "totalDebit": 505,
  "netAmount": 500,
  "currency": "GHS",
  "unconfigured": false
}
Show your customer Reconcile against
totalDebit - what they will actually pay totalDebit for your outflow, amountReceived for what landed

Warning

Do not compute the total yourself from a rate you have cached. totalDebit is authoritative for both bearer modes, and a schedule can change. Quote, then charge - do not cache a quote across a schedule change.

unconfigured: true means no charge schedule exists for that movement type and currency; charge is 0 and totalDebit equals amount.

Limits and caps

Daily limits and per-transaction caps measure the principal, not the gross. A charge does not eat into a sender's remaining daily headroom.

A daily limit can apply to one recipient rail (BANK_ACCOUNT, MTN, TELECEL, AIRTEL_TIGO, ZEE_PAY, UMO_PAY) or to all of them (ALL). A rail-specific limit takes precedence over an ALL limit for that rail. To see the charge schedules and limits PayDirect has configured specifically for your Integrator:

text
GET /integrators/{integratorId}/charges
GET /integrators/{integratorId}/limits

Both are read-only, paginated (pg, ipp), and need the TRANSACTIONS_READ scope on an API key. Platform-wide schedules and limits that apply to everyone are not included.

Sandbox keys are always capped at GHS 1.00 per transaction, and production keys are too until your Integrator is verified; while capped, amounts in other currencies are refused. Exceeding the cap returns 403 before anything is dispatched - nothing moved, and it is safe to retry at a lower amount. Test keys are never capped. See Environments and key types.

Currency

sendingCurrency and receivingCurrency are separate fields on most endpoints. Set both to "GHS". If you need to move money in another currency, talk to PayDirect before you build for it.

Worked example: a GHS 500 payout

With a SENDER-bearing GHS 5 charge:

Field Value
amountToSend 500.00
charges 5.00
totalDebit 505.00 - debited from your ledger
amountReceived 500.00 - what the recipient's account is credited

Your ledger goes down by 505. Your vendor is paid 500 and has no idea a charge existed.

Next

The ledger and settlement - where that balance lives.