Skip to content
PayDirect Docs

Docs › Accepting payments

Collections

Charge a customer's mobile money wallet or card directly from your server, and resolve the result.

POST /transactions/collect takes money from a payer and credits your ledger. It is a one-leg flow: funding only, with no onward disbursement in the same call. Pair it with a payout for "collect now, pay out later".

Scope: COLLECTIONS_WRITE. Transaction type: COLLECTION.

Choosing between collect and checkout

POST /transactions/collect Hosted checkout
Who builds the payment UI You Us
Customer data you handle Wallet number, or card details None
Mobile money Yes Yes
Card First-party integrators only Yes, for everyone
Best for An app that already has the payer's wallet number A web checkout, an invoice link, anything with a card

Warning

customerAccountType: "CARD" on this endpoint is restricted to first-party integrators; third-party integrators receive 403. Use hosted checkout for card payments - it supports both card and mobile money for every integrator, and keeps card data off your servers entirely.

BANK_ACCOUNT is not a collection method. Money comes in by wallet or card.

Request

text
POST /transactions/collect
Idempotency-Key: <a key you persisted>
Field Required Notes
amountToSend yes Decimal, major units
sendingCurrency yes e.g. "GHS"
receivingCurrency yes e.g. "GHS"
invoiceOrAccountNumber yes Your reference. Echoed on reads and webhooks.
customerAccountType yes E_WALLET or CARD
description no Shown on statements and receipts
customerEmailAddress no Receipt and notification destination

For customerAccountType: "E_WALLET":

Field Notes
customerEwalletType The payer's wallet network - see the table below
customerAccountName Name on the wallet
customerAccountNumber The mobile money number
customerEwalletType Collection
TELECEL Available
UMO_PAY Available, from a PayDirect wallet
MTN Being rolled out - until it is enabled, the request is refused with "MTN mobile money collection is not yet available"
AIRTEL_TIGO, ZEE_PAY Not yet supported for collection

A refused wallet type creates nothing and moves nothing. Offer the payer another method, or use hosted checkout.

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/collect '{
  "amountToSend": 150.75,
  "sendingCurrency": "GHS",
  "receivingCurrency": "GHS",
  "invoiceOrAccountNumber": "order-10482",
  "description": "Order #10482",
  "customerAccountType": "E_WALLET",
  "customerEwalletType": "MTN",
  "customerAccountName": "Ama Serwaa",
  "customerAccountNumber": "0244000000",
  "customerEmailAddress": "ama@example.com"
}'

Node.js

const { status, body } = await payDirect.post(
  '/transactions/collect',
  {
    amountToSend: 150.75,
    sendingCurrency: 'GHS',
    receivingCurrency: 'GHS',
    invoiceOrAccountNumber: 'order-10482',
    description: 'Order #10482',
    customerAccountType: 'E_WALLET',
    customerEwalletType: 'MTN',
    customerAccountName: 'Ama Serwaa',
    customerAccountNumber: '0244000000',
    customerEmailAddress: 'ama@example.com',
  },
  `collect:${order.id}`, // idempotency key derived from your order, persisted first
);

await orders.saveTransactionId(order.id, body.data.id);

Python

status, body = post(
    "/transactions/collect",
    {
        "amountToSend": 150.75,
        "sendingCurrency": "GHS",
        "receivingCurrency": "GHS",
        "invoiceOrAccountNumber": "order-10482",
        "description": "Order #10482",
        "customerAccountType": "E_WALLET",
        "customerEwalletType": "MTN",
        "customerAccountName": "Ama Serwaa",
        "customerAccountNumber": "0244000000",
        "customerEmailAddress": "ama@example.com",
    },
    idempotency_key=f"collect:{order.id}",  # persisted before the first attempt
)

orders.save_transaction_id(order.id, body["data"]["id"])

PHP

<?php
$payDirect = new PayDirect();

$result = $payDirect->post('/transactions/collect', [
    'amountToSend'           => 150.75,
    'sendingCurrency'        => 'GHS',
    'receivingCurrency'      => 'GHS',
    'invoiceOrAccountNumber' => 'order-10482',
    'description'            => 'Order #10482',
    'customerAccountType'    => 'E_WALLET',
    'customerEwalletType'    => 'MTN',
    'customerAccountName'    => 'Ama Serwaa',
    'customerAccountNumber'  => '0244000000',
    'customerEmailAddress'   => 'ama@example.com',
], "collect:{$order->id}"); // idempotency key, persisted before the first attempt

$orders->saveTransactionId($order->id, $result['body']['data']['id']);

Java

String payload = """
    {
      "amountToSend": 150.75,
      "sendingCurrency": "GHS",
      "receivingCurrency": "GHS",
      "invoiceOrAccountNumber": "order-10482",
      "description": "Order #10482",
      "customerAccountType": "E_WALLET",
      "customerEwalletType": "MTN",
      "customerAccountName": "Ama Serwaa",
      "customerAccountNumber": "0244000000",
      "customerEmailAddress": "ama@example.com"
    }
    """;

HttpResponse<String> response = new PayDirect().post("/transactions/collect", payload);
// Persist the `data.id` from the response body - it is the only key you can re-query with.

C#

var payDirect = new PayDirect();

var response = await payDirect.PostAsync("/transactions/collect", new
{
    amountToSend           = 150.75m, // decimal, never double
    sendingCurrency        = "GHS",
    receivingCurrency      = "GHS",
    invoiceOrAccountNumber = "order-10482",
    description            = "Order #10482",
    customerAccountType    = "E_WALLET",
    customerEwalletType    = "MTN",
    customerAccountName    = "Ama Serwaa",
    customerAccountNumber  = "0244000000",
    customerEmailAddress   = "ama@example.com",
}, idempotencyKey: $"collect:{order.Id}"); // persisted before the first attempt

Response

201, with the transaction:

json
{
  "code": 201,
  "status": "success",
  "message": "Funds collected successfully",
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440002",
    "transactionType": "COLLECTION",
    "status": "PENDING"
  }
}

Persist data.id. It is the only key you can re-query this collection with.

PENDING is the normal first state for mobile money - the payer still has to approve the prompt on their handset. Do not treat it as a failure, and do not resubmit.

Resolving the outcome

A collection credits your ledger only once funding is confirmed.

Webhooks (recommended). TRANSACTION_SUCCEEDED / TRANSACTION_FAILED arrive as soon as we know. See Webhooks overview.

Reconcile. POST /transactions/collect/{id}/reconcile re-checks the rail and updates our record. Safe to call more than once - an already-resolved transaction is a no-op and will not double-credit your ledger.

Read. GET /transactions/find/{id} returns our stored state without touching the rail.

Warning

Your ledger is credited only on confirmed success, never while the collection is PENDING. A pending collection is not money you have, and it will not fund a payout or a withdrawal.

Fulfilment

Fulfil on SUCCESS and nothing else. In particular:

  • Do not fulfil on a 201 - that only means the request was accepted.
  • Do not fulfil on PENDING.
  • Make fulfilment idempotent on our transaction id. A webhook can arrive more than once (see retries), and a reconcile sweep can observe the same success your webhook handler already processed.

Node.js

// Idempotent on our id, so a duplicate webhook or a reconcile cannot double-ship.
async function onCollectionSucceeded(transactionId) {
  const claimed = await db.markFulfilled(transactionId); // INSERT ... ON CONFLICT DO NOTHING
  if (!claimed) return;
  await shipOrder(transactionId);
}

Python

# Idempotent on our id, so a duplicate webhook or a reconcile cannot double-ship.
def on_collection_succeeded(transaction_id: str) -> None:
    if not db.mark_fulfilled(transaction_id):  # INSERT ... ON CONFLICT DO NOTHING
        return
    ship_order(transaction_id)

PHP

<?php
// Idempotent on our id, so a duplicate webhook or a reconcile cannot double-ship.
function onCollectionSucceeded(string $transactionId): void
{
    if (!markFulfilled($transactionId)) { // INSERT ... ON CONFLICT DO NOTHING
        return;
    }
    shipOrder($transactionId);
}

Java

// Idempotent on our id, so a duplicate webhook or a reconcile cannot double-ship.
void onCollectionSucceeded(String transactionId) {
    if (!repository.markFulfilled(transactionId)) { // INSERT ... ON CONFLICT DO NOTHING
        return;
    }
    shipOrder(transactionId);
}

C#

// Idempotent on our id, so a duplicate webhook or a reconcile cannot double-ship.
async Task OnCollectionSucceededAsync(string transactionId)
{
    if (!await db.MarkFulfilledAsync(transactionId)) // INSERT ... ON CONFLICT DO NOTHING
        return;

    await ShipOrderAsync(transactionId);
}

Common rejections

Code Cause
400 Missing a required field, an e-wallet field absent for E_WALLET, or a wallet type not available for collection
403 CARD attempted by a third-party integrator, or the key lacks COLLECTIONS_WRITE
409 Same Idempotency-Key, still in flight
403 Over the GHS 1.00 per-transaction limit on a sandbox key, or on a production key while your Integrator is unverified - see Environments and key types

Full table in Errors and status codes.

Next

Hosted checkout - let us build the payment page.