Skip to content
PayDirect Docs

Docs › Getting started

Quickstart

Collect your first payment in the sandbox - sign a request, read the result, and confirm the outcome.

This takes about five minutes and ends with a real transaction id in your sandbox. You need a test key trio (pk_test_…, the raw API key, and sk_test_…) - see API keys and credentials if you do not have one.

Nothing here moves real money: every money movement on a test key is mocked. See Environments and key types.

1. Set your credentials

bash
export PAYDIRECT_HOST="https://<your-assigned-host>/api/v1"
export PAYDIRECT_PUBLIC_KEY="pk_test_…"
export PAYDIRECT_RAW_KEY="…"        # the raw API key
export PAYDIRECT_SECRET_KEY="sk_test_…"

Caution

The secret key is shown exactly once, when the key is issued. Store it (and the raw API key) in a secret manager, never in source control, and never send the secret key over the wire - it is only ever used locally to compute a signature.

2. Make a signed request

Every request carries three headers, and the signature is a fresh HMAC per request. Pick your language - the choice sticks for every example on this site.

Each of these is complete and standalone, so you can paste and run it now. Once it works, move the signing into the reusable client in Authentication rather than repeating it.

cURL

TS=$(date +%s000)
SIG=$(printf '%s:%s' "$PAYDIRECT_RAW_KEY" "$TS" | openssl dgst -sha256 -hmac "$PAYDIRECT_SECRET_KEY" -hex | awk '{print $2}')

curl -s -X POST "$PAYDIRECT_HOST/transactions/collect" \
  -H "Content-Type: application/json" \
  -H "x-api-key-id: $PAYDIRECT_PUBLIC_KEY" \
  -H "x-api-timestamp: $TS" \
  -H "x-api-signature: $SIG" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "amountToSend": 1.00,
    "sendingCurrency": "GHS",
    "receivingCurrency": "GHS",
    "invoiceOrAccountNumber": "quickstart-001",
    "description": "Quickstart test collection",
    "customerAccountType": "E_WALLET",
    "customerEwalletType": "MTN",
    "customerAccountName": "Ama Serwaa",
    "customerAccountNumber": "0244000000"
  }'

Node.js

// quickstart.js - Node 18+, no dependencies. Run: node quickstart.js
const crypto = require('crypto');

async function collect() {
  const timestamp = Date.now().toString();
  const signature = crypto
    .createHmac('sha256', process.env.PAYDIRECT_SECRET_KEY)
    .update(`${process.env.PAYDIRECT_RAW_KEY}:${timestamp}`)
    .digest('hex');

  const response = await fetch(`${process.env.PAYDIRECT_HOST}/transactions/collect`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key-id': process.env.PAYDIRECT_PUBLIC_KEY,
      'x-api-timestamp': timestamp,
      'x-api-signature': signature,
      // Makes this request safe to retry. See the Idempotency guide.
      'Idempotency-Key': crypto.randomUUID(),
    },
    body: JSON.stringify({
      amountToSend: 1.0,
      sendingCurrency: 'GHS',
      receivingCurrency: 'GHS',
      invoiceOrAccountNumber: `quickstart-${Date.now()}`,
      description: 'Quickstart test collection',
      customerAccountType: 'E_WALLET',
      customerEwalletType: 'MTN',
      customerAccountName: 'Ama Serwaa',
      customerAccountNumber: '0244000000',
    }),
  });

  console.log(response.status, await response.json());
}

collect();

Python

# quickstart.py - pip install requests. Run: python quickstart.py
import hashlib, hmac, os, time, uuid
import requests

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()

response = requests.post(
    f'{os.environ["PAYDIRECT_HOST"]}/transactions/collect',
    headers={
        "x-api-key-id": os.environ["PAYDIRECT_PUBLIC_KEY"],
        "x-api-timestamp": timestamp,
        "x-api-signature": signature,
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "amountToSend": 1.00,
        "sendingCurrency": "GHS",
        "receivingCurrency": "GHS",
        "invoiceOrAccountNumber": f"quickstart-{timestamp}",
        "description": "Quickstart test collection",
        "customerAccountType": "E_WALLET",
        "customerEwalletType": "MTN",
        "customerAccountName": "Ama Serwaa",
        "customerAccountNumber": "0244000000",
    },
    timeout=30,
)

print(response.status_code, response.json())

PHP

<?php
// quickstart.php - needs ext-curl. Run: php quickstart.php

$timestamp = (string) round(microtime(true) * 1000); // milliseconds, not seconds
$signature = hash_hmac(
    'sha256',
    getenv('PAYDIRECT_RAW_KEY') . ':' . $timestamp,
    getenv('PAYDIRECT_SECRET_KEY')
);

$body = json_encode([
    'amountToSend'           => 1.00,
    'sendingCurrency'        => 'GHS',
    'receivingCurrency'      => 'GHS',
    'invoiceOrAccountNumber' => 'quickstart-' . $timestamp,
    'description'            => 'Quickstart test collection',
    'customerAccountType'    => 'E_WALLET',
    'customerEwalletType'    => 'MTN',
    'customerAccountName'    => 'Ama Serwaa',
    'customerAccountNumber'  => '0244000000',
]);

$ch = curl_init(getenv('PAYDIRECT_HOST') . '/transactions/collect');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POSTFIELDS     => $body,
    CURLOPT_HTTPHEADER     => [
        'Content-Type: application/json',
        'x-api-key-id: ' . getenv('PAYDIRECT_PUBLIC_KEY'),
        'x-api-timestamp: ' . $timestamp,
        'x-api-signature: ' . $signature,
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
]);

echo curl_exec($ch), PHP_EOL;
echo curl_getinfo($ch, CURLINFO_RESPONSE_CODE), PHP_EOL;
curl_close($ch);

Java

// Quickstart.java - JDK 11+, no dependencies. Run: java Quickstart.java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.util.UUID;

public class Quickstart {
    public static void main(String[] args) throws Exception {
        String timestamp = String.valueOf(System.currentTimeMillis()); // milliseconds

        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(
                System.getenv("PAYDIRECT_SECRET_KEY").getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        byte[] digest = mac.doFinal(
                (System.getenv("PAYDIRECT_RAW_KEY") + ":" + timestamp).getBytes(StandardCharsets.UTF_8));

        StringBuilder signature = new StringBuilder();
        for (byte b : digest) {
            signature.append(String.format("%02x", b));
        }

        String payload = """
            {
              "amountToSend": 1.00,
              "sendingCurrency": "GHS",
              "receivingCurrency": "GHS",
              "invoiceOrAccountNumber": "quickstart-001",
              "description": "Quickstart test collection",
              "customerAccountType": "E_WALLET",
              "customerEwalletType": "MTN",
              "customerAccountName": "Ama Serwaa",
              "customerAccountNumber": "0244000000"
            }
            """;

        HttpRequest request = HttpRequest.newBuilder(
                        URI.create(System.getenv("PAYDIRECT_HOST") + "/transactions/collect"))
                .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.toString())
                .header("Idempotency-Key", UUID.randomUUID().toString())
                .POST(HttpRequest.BodyPublishers.ofString(payload))
                .build();

        HttpResponse<String> response =
                HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
        System.out.println(response.statusCode() + " " + response.body());
    }
}

C#

// Quickstart.cs - .NET 6+, no packages. Run: dotnet run
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;

// Milliseconds since the epoch, not seconds.
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();

using var mac = new HMACSHA256(
    Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("PAYDIRECT_SECRET_KEY")!));
var signature = Convert.ToHexString(mac.ComputeHash(Encoding.UTF8.GetBytes(
    $"{Environment.GetEnvironmentVariable("PAYDIRECT_RAW_KEY")}:{timestamp}"))).ToLowerInvariant();

using var http = new HttpClient();
using var request = new HttpRequestMessage(
    HttpMethod.Post,
    Environment.GetEnvironmentVariable("PAYDIRECT_HOST") + "/transactions/collect");

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);
request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
request.Content = JsonContent.Create(new
{
    amountToSend           = 1.00m, // decimal, never double
    sendingCurrency        = "GHS",
    receivingCurrency      = "GHS",
    invoiceOrAccountNumber = $"quickstart-{timestamp}",
    description            = "Quickstart test collection",
    customerAccountType    = "E_WALLET",
    customerEwalletType    = "MTN",
    customerAccountName    = "Ama Serwaa",
    customerAccountNumber  = "0244000000",
});

var response = await http.SendAsync(request);
Console.WriteLine((int)response.StatusCode + " " + await response.Content.ReadAsStringAsync());

3. Read the result

A 201 with the standard envelope:

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 our reference for this transaction and the only key you can re-query it with. Your own invoiceOrAccountNumber is echoed back on reads and webhooks for correlation, but it is not a lookup key.

status is one of PENDING, SUCCESS or FAILED. Mobile money is asynchronous, so a fresh collection is normally PENDING - the payer still has to approve the prompt on their phone.

4. Confirm the outcome

Two ways, and you should use the first in production:

Webhooks (push). Configure a webhookUrl and we call you on TRANSACTION_SUCCEEDED / TRANSACTION_FAILED. See Webhooks overview.

Reconcile (pull). Ask us to re-check the rail and return the current state:

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

# Re-sign: a timestamp older than five minutes is rejected.
TS=$(date +%s000)
SIG=$(printf '%s:%s' "$PAYDIRECT_RAW_KEY" "$TS" | openssl dgst -sha256 -hmac "$PAYDIRECT_SECRET_KEY" -hex | awk '{print $2}')

curl -s -X POST "$PAYDIRECT_HOST/transactions/collect/$TRANSACTION_ID/reconcile" \
  -H "x-api-key-id: $PAYDIRECT_PUBLIC_KEY" \
  -H "x-api-timestamp: $TS" \
  -H "x-api-signature: $SIG"

Node.js

const { body } = await payDirect.post(`/transactions/collect/${transactionId}/reconcile`);
console.log(body.data.status); // PENDING | SUCCESS | FAILED

Python

status, body = post(f"/transactions/collect/{transaction_id}/reconcile", {})
print(body["data"]["status"])  # PENDING | SUCCESS | FAILED

PHP

<?php
$result = (new PayDirect())->post("/transactions/collect/{$transactionId}/reconcile", []);
echo $result['body']['data']['status']; // PENDING | SUCCESS | FAILED

Java

HttpResponse<String> response =
        new PayDirect().post("/transactions/collect/" + transactionId + "/reconcile", "{}");
System.out.println(response.body()); // read data.status: PENDING | SUCCESS | FAILED

C#

var response = await new PayDirect()
    .PostAsync($"/transactions/collect/{transactionId}/reconcile", new { });
Console.WriteLine(await response.Content.ReadAsStringAsync()); // read data.status

GET /transactions/find/{id} reads our stored state without re-checking the rail - cheaper, and the right call once a webhook has already told you the transaction resolved.

Tip

Poll with backoff, not in a tight loop - every request counts against your rate limit. A reconcile at 10s, 30s, 2m and 5m covers almost every real mobile money approval.

What you just relied on

Thing Guide
The x-api-* headers and the HMAC Authentication
Idempotency-Key making a retry safe Idempotency
PENDING → SUCCESS/FAILED and terminality Transactions
The code/status/message/data envelope Requests and responses

Next

Authentication - signing in depth, and what each failure mode looks like.