Skip to content
PayDirect Docs

Docs › Reference

Reconciliation

A playbook for keeping your books and ours in agreement - daily sweeps, resolving stuck transactions, and the questions integrators ask most.

Webhooks and synchronous responses cover the normal case. Reconciliation is what covers the rest: a missed delivery, an endpoint that was down, a 502 with no transaction id, a process that crashed between submitting and recording.

Every integration that moves real money needs one. It does not need to be complicated.

The daily sweep

What to do with each transaction the sweep finds on only one side.

Once a day, for each currency:

  1. List our side over the window.
    text
    GET /transactions/list?from=2026-08-19T00:00:00.000Z&to=2026-08-19T23:59:59.999Z&pg=1&ipp=100
    
  2. Match on our transaction id, which you persisted at submission. Fall back to your own invoiceOrAccountNumber only for records where you never received an id.
  3. Resolve anything still PENDING older than your expected settlement time - reconcile it.
  4. Investigate anything on one side only (see the two cases below).
  5. Reconcile the ledger. Compare GET /integrators/{id}/ledger/entries over the same window against your own balance movements. Entries are immutable and each references its transaction, so a disagreement resolves to a specific id rather than a mystery difference.
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);
    }
}

The sweep in step 1, as code:

cURL

paydirect GET "/transactions/list?from=2026-08-19T00:00:00.000Z&to=2026-08-19T23:59:59.999Z&pg=1&ipp=100"

Node.js

const { status, body } = await payDirect.get('/transactions/list?from=2026-08-19T00:00:00.000Z&to=2026-08-19T23:59:59.999Z&pg=1&ipp=100');

Python

status, body = get("/transactions/list?from=2026-08-19T00:00:00.000Z&to=2026-08-19T23:59:59.999Z&pg=1&ipp=100")

PHP

<?php
$result = (new PayDirect())->get('/transactions/list?from=2026-08-19T00:00:00.000Z&to=2026-08-19T23:59:59.999Z&pg=1&ipp=100');

Java

HttpResponse<String> response = new PayDirect().get("/transactions/list?from=2026-08-19T00:00:00.000Z&to=2026-08-19T23:59:59.999Z&pg=1&ipp=100");

C#

var response = await new PayDirect().GetAsync("/transactions/list?from=2026-08-19T00:00:00.000Z&to=2026-08-19T23:59:59.999Z&pg=1&ipp=100");

In your books but not ours

You recorded a submission and we have no record. Check the HTTP status you stored:

You saw Means
A 2xx with an id, and GET /transactions/find/{id} returns 404 Should not happen - escalate with the id
400 / 401 / 403 / 422 / 429 Rejected before dispatch. Nothing exists. Safe to resubmit.
A timeout, or 502 Unknown. Resubmit with the same Idempotency-Key - if the original did get through, you get the original response back rather than a second payment.

In our books but not yours

We have a transaction you never recorded - typically a response lost in transit. Its invoiceOrAccountNumber is your own reference, which is what lets you attach it to the right order after the fact. Import it and reconcile forward.

Resolving a stuck PENDING

text
POST /transactions/collect/{id}/reconcile     # or the payout / withdraw / pay-bill equivalent

cURL

paydirect POST "/transactions/collect/$TRANSACTION_ID/reconcile" '{}'

Node.js

const { status, body } = await payDirect.post(
  `/transactions/collect/${transactionId}/reconcile`,
  {},
);

Python

status, body = post(
    f"/transactions/collect/{transaction_id}/reconcile",
    {},
)

PHP

<?php
$result = (new PayDirect())->post("/transactions/collect/{$transactionId}/reconcile", []);

Java

HttpResponse<String> response = new PayDirect().post("/transactions/collect/" + transactionId + "/reconcile", "{}");

C#

var response = await new PayDirect().PostAsync($"/transactions/collect/{transactionId}/reconcile", new { });

Reconcile re-checks the rail and updates our record. It is safe to call more than once - an already-resolved transaction is a no-op and cannot double-credit your ledger.

If it stays PENDING well past the rail's normal settlement time, pull GET /transactions/{id}/journey-snapshot and send it to support. That snapshot shows each stage and the rail's own responses, and it is the single most useful thing to attach to a ticket.

Tip

Back off rather than polling hard: 10s, 30s, 2m, 5m, then every 15 minutes, then hand it to the daily sweep. A tight loop burns your rate limit and does not make a mobile money prompt get approved any faster.


Frequently asked, answered precisely

These are the questions integrators ask during their first reconciliation build.

Which statuses exist, and which are certain?

Three, and nothing else. If you ever see a value not on this list, treat it as unknown.

Status Bucket Meaning
PENDING We do not know yet In flight or awaiting rail confirmation. Poll or wait for the webhook.
SUCCESS Money arrived Confirmed by the rail.
FAILED Money did not leave Rejected or confirmed failed. A ledger debit already taken is returned to your balance.

Can a status go backwards?

No - for PAYOUT and WITHDRAWAL. SUCCESS and FAILED are terminal statuses: reconciliation does not change them once resolved. If a bank or network recalls a payment after the fact - rare, and outside the API - it is handled with you through support, not by silently changing the status.

That applies to those two types. Other flows (a bill payment, say) have their own reconciliation logic; if you depend on the same behaviour there, ask us to confirm for that path rather than assuming it.

What do I re-query with - your reference or mine?

Ours. GET /transactions/find/{id} and every reconcile endpoint take the transaction id we returned when you submitted.

There is currently no lookup by a reference you supplied. Persist our id from the submission response - today that is the only reliable key. If losing it would be a real failure mode in your flow, tell us and we will prioritise a lookup-by-your-reference endpoint.

How do I tell "never seen" from "failed"?

By HTTP status code, not by the body:

  • An id we have never seen → 404, "Transaction not found".
  • A transaction that exists and resolved failed → 200, with data.status: "FAILED".

If I send the same payment twice, do you execute it twice?

Not if you send Idempotency-Key, which is the only mechanism that guarantees a retry returns the original response rather than executing again:

  • Same key, identical body, accepted the first time → the original response, not re-executed.
  • Same key, still in flight → 409.
  • Same key, different body → 422.
  • Same key, identical body, rejected the first time (4xx/5xx) → runs again.

PayDirect also has its own safeguards against duplicate payouts, but they do not return your original response - so they are not a substitute for the header. Send the header on everything that moves money. See Idempotency.

Which HTTP codes mean nothing moved?

400, 401, 403, 422 and 429 all mean the request was not accepted and nothing was dispatched. 502 is the one genuinely uncertain outcome - the rail call errored with nothing we could persist, so there is no transaction id to reconcile against. The full table is in Errors and status codes.

Are callbacks signed, and can they arrive twice?

Yes and yes. HMAC-SHA256 in X-UmoPay-Signature, over "{t}.{raw body}", format t=<unix-ms>,v1=<hex>. Handle a webhook arriving before your own request's response - rare, but possible. Delivery is at-least-once, so dedupe on eventId. See Verifying signatures.

If no signing secret is configured, no signature header is sent at all - make sure you hold one, and reject unsigned deliveries.

When you report an amount, is that what the recipient gets or what I am debited?

Both, in different fields:

  • amountToSend / amountReceived - the net principal the recipient receives.
  • totalDebit - what was actually debited from you, charge (and any commission) included.
  • charges - the charge itself.
  • commission - your own Integrator commission, if you set one. It is credited to your ledger when the transaction succeeds.

The default charge bearer is SENDER: the recipient receives the full principal and the charge is added on top of your debit. See Amounts and charges.


If something above does not match what you are seeing, send us the transaction id and the timestamp. See Support.

Next

Going live - the checklist before you switch to pk_live_.