Skip to content
PayDirect Docs

Docs › Accepting payments

Hosted checkout

Generate a payment link, let us collect card or mobile money on a page we host, and confirm the result server-to-server.

POST /checkout/generate-payment-link returns a URL you send your customer to. We render the payment page, handle card and mobile money, and tell you the outcome. You never touch card data.

This is the right choice for a web checkout, an emailed invoice, a WhatsApp payment request - and it is the only way a third-party integrator can accept cards.

The flow

A hosted checkout, end to end. Only the two solid green paths are proof of payment.
text
POST /checkout/generate-payment-link

Provide either a flat amount or an items cart - not both.

Field Required Notes
currency yes e.g. "GHS"
amount either Flat amount. Mutually exclusive with items.
items either Cart array, at least one entry, unique by id. Mutually exclusive with amount.
description no Shown to the payer
reference no Your own reference
invoiceNumber no Your invoice number
expiresInMinutes no Default 30, clamped server-side to 1440 (24h)

Cart items

Field Required Notes
id yes Unique within the cart
name yes Shown to the payer on the checkout page
quantity yes Must be positive. Not restricted to whole numbers, so 2.5 is accepted for something sold by weight
price yes Must be positive - a zero-price line is rejected, so a free item cannot be listed
description no Not currently shown on the payment page
imgUrl no Rendered as a thumbnail beside the line. Not validated as a URL, so send a real absolute one

You do not send a total. We compute it as the sum of quantity × price across the cart, and that computed figure is what the payer is charged - so a client that miscalculates its own total cannot overcharge or undercharge. Each line comes back with a server-computed subtotal.

Note

The create response returns only the session (id, checkoutUrl, amount, currency, expiresAt, status) - it does not echo the items back. GET /checkout/check-status/{id} does return the priced cart, including each line's subtotal.

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 /checkout/generate-payment-link '{
  "currency": "GHS",
  "items": [
    { "id": "prod-001", "name": "Premium Subscription", "quantity": 2, "price": 125.00 }
  ],
  "description": "Order #4432",
  "reference": "order-4432",
  "expiresInMinutes": 60
}'

Node.js

const { body } = await payDirect.post('/checkout/generate-payment-link', {
  currency: 'GHS',
  items: [{ id: 'prod-001', name: 'Premium Subscription', quantity: 2, price: 125.0 }],
  description: 'Order #4432',
  reference: `order-${order.id}`, // comes back on the webhook, so you can correlate
  expiresInMinutes: 60,
});

// Send the payer to checkoutUrl as returned; never build it from `id`.
redirect(body.data.checkoutUrl);

Python

status, body = post("/checkout/generate-payment-link", {
    "currency": "GHS",
    "items": [{"id": "prod-001", "name": "Premium Subscription", "quantity": 2, "price": 125.00}],
    "description": "Order #4432",
    "reference": f"order-{order.id}",  # comes back on the webhook, so you can correlate
    "expiresInMinutes": 60,
})

# Send the payer to checkoutUrl as returned; never build it from `id`.
redirect(body["data"]["checkoutUrl"])

PHP

<?php
$result = (new PayDirect())->post('/checkout/generate-payment-link', [
    'currency'         => 'GHS',
    'items'            => [
        ['id' => 'prod-001', 'name' => 'Premium Subscription', 'quantity' => 2, 'price' => 125.00],
    ],
    'description'      => 'Order #4432',
    'reference'        => "order-{$order->id}", // comes back on the webhook
    'expiresInMinutes' => 60,
]);

// Send the payer to checkoutUrl as returned; never build it from `id`.
header('Location: ' . $result['body']['data']['checkoutUrl']);

Java

String payload = """
    {
      "currency": "GHS",
      "items": [
        { "id": "prod-001", "name": "Premium Subscription", "quantity": 2, "price": 125.00 }
      ],
      "description": "Order #4432",
      "reference": "order-4432",
      "expiresInMinutes": 60
    }
    """;

HttpResponse<String> response = new PayDirect().post("/checkout/generate-payment-link", payload);
// Send the payer to data.checkoutUrl as returned; never build it from data.id.

C#

var response = await new PayDirect().PostAsync("/checkout/generate-payment-link", new
{
    currency = "GHS",
    items = new[]
    {
        new { id = "prod-001", name = "Premium Subscription", quantity = 2, price = 125.00m },
    },
    description      = "Order #4432",
    reference        = $"order-{order.Id}", // comes back on the webhook
    expiresInMinutes = 60,
});
// Send the payer to data.checkoutUrl as returned; never build it from data.id.

Response 201:

json
{
  "data": {
    "id": "9f2b1e10-6b3a-4c1a-8c2a-1a2b3c4d5e6f",
    "currency": "GHS",
    "amount": 250,
    "status": "PENDING",
    "expiresAt": "2026-08-19T21:00:00.000Z",
    "checkoutUrl": "https://<your-assigned-host>/api/v1/checkout/3f7a1c9e…"
  }
}

Caution

id and the token inside checkoutUrl are different, independent values: id is a record identifier, the token is the payer's key to the page. Use id for your own records and for check-status. Never try to build a checkout URL from id; send the payer the checkoutUrl exactly as returned.

A failed payment attempt does not burn the link - the payer can retry until it expires. The payment page includes a CAPTCHA and limits how often one payer can submit, so a real payer is never affected, but an automated script driving the page will be.

Confirm the payment

Warning

Never treat the payer's browser reaching your success page as proof of payment. A redirect is a URL anyone can visit. Confirm server-to-server, always.

Webhook. CHECKOUT_SESSION_PAID fires when the session's transaction resolves SUCCESS. It is sent in addition to the underlying TRANSACTION_SUCCEEDED event, so make your handler idempotent - see Webhooks overview.

Poll. GET /checkout/check-status/{id} is the verify-by-reference equivalent:

cURL

paydirect GET /checkout/check-status/9f2b1e10-6b3a-4c1a-8c2a-1a2b3c4d5e6f

Node.js

const { body } = await payDirect.get(`/checkout/check-status/${sessionId}`);
if (body.data.status === 'SUCCESS') await fulfil(order.id);

Python

status, body = get(f"/checkout/check-status/{session_id}")
if body["data"]["status"] == "SUCCESS":
    fulfil(order.id)

PHP

<?php
$result = (new PayDirect())->get("/checkout/check-status/{$sessionId}");
if ($result['body']['data']['status'] === 'SUCCESS') {
    fulfil($order->id);
}

Java

HttpResponse<String> response = new PayDirect().get("/checkout/check-status/" + sessionId);
// Fulfil only when data.status is SUCCESS.

C#

var response = await new PayDirect().GetAsync($"/checkout/check-status/{sessionId}");
// Fulfil only when data.status is SUCCESS.

Sessions are scoped to the Integrator that created them; querying another integrator's session returns 403.

Use both: the webhook for latency, check-status as the authority you re-check before fulfilling.

Bringing the customer back

Set a callbackUrl and we redirect the payer there when they finish:

cURL

paydirect PATCH /integrators/$INTEGRATOR_ID/callback-url \
  '{ "callbackUrl": "https://yourapp.com/checkout/return" }'

Node.js

await payDirect.patch(`/integrators/${integratorId}/callback-url`, {
  callbackUrl: 'https://yourapp.com/checkout/return', // https only
});

Python

status, body = patch(f"/integrators/{integrator_id}/callback-url", {
    "callbackUrl": "https://yourapp.com/checkout/return",  # https only
})

PHP

<?php
(new PayDirect())->patch("/integrators/{$integratorId}/callback-url", [
    'callbackUrl' => 'https://yourapp.com/checkout/return', // https only
]);

Java

new PayDirect().patch("/integrators/" + integratorId + "/callback-url",
        """
        { "callbackUrl": "https://yourapp.com/checkout/return" }
        """);

C#

await new PayDirect().PatchAsync(
    $"/integrators/{integratorId}/callback-url",
    new { callbackUrl = "https://yourapp.com/checkout/return" }); // https only

HTTPS only, and it is a single Integrator-wide URL shared by TEST and LIVE. Treat the page it serves as "we're checking…" - look the session up by your own reference, call check-status, and only then show a result.

Handling expiry

A link that is never paid simply expires. Nothing is charged, and no transaction exists. If the customer still wants to pay, generate a fresh link - do not try to revive an expired one.

Set expiresInMinutes to match the context: minutes for an in-session web checkout, hours for an emailed invoice.

Reconciling

The paid session produces a COLLECTION transaction like any other, which credits your ledger and appears in GET /transactions/list. Your reference travels through to the transaction's invoiceOrAccountNumber, so you can correlate a webhook back to your own order without already knowing our transaction id - which, for a checkout link, you could not have known when you created it.

Next

Bill payments, or Payouts to pay money out again.