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 receive403. 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
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:
{
"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
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.