Skip to content
PayDirect Docs

Docs › Core concepts

Requests and responses

The envelope every endpoint returns, how to read it correctly, and the shared conventions for pagination, dates and money.

Every PayDirect endpoint speaks JSON over HTTPS and answers in the same envelope, whether it succeeded or failed. Write one parser and one error handler; they will work everywhere.

The envelope

json
{
  "code": 200,
  "status": "success",
  "message": "Action completed successfully",
  "data": { }
}
Field Type Notes
code number Mirrors the HTTP status code.
status string "success" or "error".
message string Human-readable. Useful in logs and support tickets; do not branch on its text - wording can change without notice.
data object / array The result. Absent on most errors.

Branch on the HTTP status code and, for money movement, on data.status. Never on message.

The one exception you must handle

On money-movement endpoints, a 200/201 response can carry "status": "error". That is not a malformed response - it means the request was accepted, the payment was dispatched to the rail, and the rail definitively rejected it. It is a resolved outcome, not an ambiguous one.

json
{
  "code": 200,
  "status": "error",
  "message": "Disbursement failed at the rail",
  "data": { "id": "…", "status": "FAILED" }
}
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);
    }
}

Node.js

// Correct
const { status, body } = await payDirect.post('/transactions/disburse', payload, key);
if (status < 400 && body.data?.status === 'SUCCESS') { /* settled */ }
else if (status < 400 && body.data?.status === 'PENDING') { /* wait for webhook */ }
else if (status < 400 && body.data?.status === 'FAILED') { /* definitively failed */ }
else { /* not accepted - see the Errors guide */ }

// Wrong: treats a definitively failed payout as a completed one
if (status < 400) { markAsPaid(); }

Python

# Correct
status, body = post("/transactions/disburse", payload, key)
data_status = (body.get("data") or {}).get("status")

if status < 400 and data_status == "SUCCESS":
    settle()
elif status < 400 and data_status == "PENDING":
    wait_for_webhook()
elif status < 400 and data_status == "FAILED":
    handle_definite_failure()
else:
    handle_rejection()  # see the Errors guide

# Wrong: treats a definitively failed payout as a completed one
if status < 400:
    mark_as_paid()

PHP

<?php
// Correct
$result = (new PayDirect())->post('/transactions/disburse', $payload, $key);
$dataStatus = $result['body']['data']['status'] ?? null;

if ($result['status'] < 400 && $dataStatus === 'SUCCESS') {
    settle();
} elseif ($result['status'] < 400 && $dataStatus === 'PENDING') {
    waitForWebhook();
} elseif ($result['status'] < 400 && $dataStatus === 'FAILED') {
    handleDefiniteFailure();
} else {
    handleRejection(); // see the Errors guide
}

// Wrong: treats a definitively failed payout as a completed one
if ($result['status'] < 400) { markAsPaid(); }

Java

// Correct
HttpResponse<String> response = new PayDirect().post("/transactions/disburse", payload);
String dataStatus = readDataStatus(response.body());

if (response.statusCode() < 400 && "SUCCESS".equals(dataStatus))      settle();
else if (response.statusCode() < 400 && "PENDING".equals(dataStatus)) waitForWebhook();
else if (response.statusCode() < 400 && "FAILED".equals(dataStatus))  handleDefiniteFailure();
else                                                                  handleRejection();

// Wrong: treats a definitively failed payout as a completed one
if (response.statusCode() < 400) markAsPaid();

C#

// Correct
var response = await new PayDirect().PostAsync("/transactions/disburse", payload, key);
var envelope = await response.Content.ReadFromJsonAsync<Envelope>();
var ok = (int)response.StatusCode < 400;

if (ok && envelope?.Data?.Status == "SUCCESS")      Settle();
else if (ok && envelope?.Data?.Status == "PENDING") WaitForWebhook();
else if (ok && envelope?.Data?.Status == "FAILED")  HandleDefiniteFailure();
else                                                HandleRejection();

// Wrong: treats a definitively failed payout as a completed one
if (ok) MarkAsPaid();

Errors and status codes has the complete mapping from HTTP code to "did money move?".

Headers you send

Header When
Content-Type: application/json Every request with a body
x-api-key-id, x-api-timestamp, x-api-signature Every request - see Authentication
Idempotency-Key Every write, and unconditionally on anything that moves money - see Idempotency

Pagination

List endpoints share the same query parameters:

Parameter Meaning
pg Page number, 1-based. Default 1.
ipp Items per page.
from Range start, ISO 8601 (2026-08-01T00:00:00.000Z)
to Range end, ISO 8601
text
GET /transactions/list?pg=2&ipp=50&from=2026-08-01T00:00:00.000Z&to=2026-08-31T23:59:59.999Z

Page through until a page returns fewer than ipp items. For reconciliation sweeps, prefer a bounded from/to window over walking the whole history - it is faster and far kinder to your rate limit.

Dates

All timestamps are ISO 8601 with an explicit Z. Send them that way too. Do not send local times without an offset.

Money

Amounts are decimal numbers in major units - 150.75 means one hundred fifty cedis and seventy-five pesewas, not pesewas. Currency is a separate ISO 4217 string ("GHS").

Warning

Do not hold amounts in a binary float. 0.1 + 0.2 is not 0.3, and a reconciliation that compares floats will eventually disagree with ours over a fraction of a pesewa. Use a decimal type, or integer minor units internally and convert at the boundary.

Several amount fields travel together and mean different things - amountToSend, amountReceived, totalDebit and charges. Amounts and charges explains which to show a customer and which to reconcile against.

Identifiers

Transaction and Integrator ids are UUID strings. Treat them as opaque: do not parse them, and store them as strings at full length.

Your own reference travels as invoiceOrAccountNumber on most write endpoints, and comes back on reads and webhooks so you can correlate without already knowing our id. It is not a lookup key - there is no find-by-your-reference endpoint. Persist our id from the creation response.

Next

Idempotency - how to retry safely.