Skip to content
PayDirect Docs

Docs › Sending money

Bill payments

Pay a biller on a customer's behalf - either from stored records, or with account details supplied inline.

A bill payment collects from a payer and settles to a biller in one journey. There are two ways in, depending on whether you keep customer and biller records with us.

POST /transactions/pay-bill POST /transactions/pay-bill-ext
Payer's funding A stored fundingSourceId Account details inline in the request
Biller A stored billerId merchant* fields inline (optional)
Auth Bearer session or signed API key Signed API key
Best for A product where customers have saved profiles with us A gateway-style integration holding its own customer records

Both are TRANSACTION_TYPE: PAY_BILL and both carry the TRANSACTIONS_WRITE scope. With a test key both are fully mocked; see Environments and key types.

Stored records: pay-bill

text
POST /transactions/pay-bill
Idempotency-Key: <a key you persisted>
Field Required
amountToSend yes
sendingCurrency, receivingCurrency yes
billerId yes - from GET /billers/list
fundingSourceId yes - from GET /fundingsources/list
invoiceOrAccountNumber The customer's account number with the biller
description no
customerCardCVV When the funding source is a card that requires it
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/pay-bill" '{
  "amountToSend": 85,
  "sendingCurrency": "GHS",
  "receivingCurrency": "GHS",
  "billerId": "BILLER_ID",
  "fundingSourceId": "FUNDING_SOURCE_ID",
  "invoiceOrAccountNumber": "ECG-0451182",
  "description": "Electricity - August"
}'

Node.js

const { status, body } = await payDirect.post(
  '/transactions/pay-bill',
  {
    amountToSend: 85,
    sendingCurrency: 'GHS',
    receivingCurrency: 'GHS',
    billerId: 'BILLER_ID',
    fundingSourceId: 'FUNDING_SOURCE_ID',
    invoiceOrAccountNumber: 'ECG-0451182',
    description: 'Electricity - August',
  },
  `bill:${invoice.id}`,
);

Python

status, body = post(
    "/transactions/pay-bill",
    {
        "amountToSend": 85,
        "sendingCurrency": "GHS",
        "receivingCurrency": "GHS",
        "billerId": "BILLER_ID",
        "fundingSourceId": "FUNDING_SOURCE_ID",
        "invoiceOrAccountNumber": "ECG-0451182",
        "description": "Electricity - August",
    },
    idempotency_key=f"bill:{invoice.id}",
)

PHP

<?php
$result = (new PayDirect())->post('/transactions/pay-bill', [
    'amountToSend' => 85,
    'sendingCurrency' => 'GHS',
    'receivingCurrency' => 'GHS',
    'billerId' => 'BILLER_ID',
    'fundingSourceId' => 'FUNDING_SOURCE_ID',
    'invoiceOrAccountNumber' => 'ECG-0451182',
    'description' => 'Electricity - August',
], "bill:{$invoice->id}");

Java

String payload = """
    {
      "amountToSend": 85,
      "sendingCurrency": "GHS",
      "receivingCurrency": "GHS",
      "billerId": "BILLER_ID",
      "fundingSourceId": "FUNDING_SOURCE_ID",
      "invoiceOrAccountNumber": "ECG-0451182",
      "description": "Electricity - August"
    }
    """;

HttpResponse<String> response = new PayDirect().post("/transactions/pay-bill", payload);

C#

var response = await new PayDirect().PostAsync("/transactions/pay-bill", new
{
    amountToSend = 85,
    sendingCurrency = "GHS",
    receivingCurrency = "GHS",
    billerId = "BILLER_ID",
    fundingSourceId = "FUNDING_SOURCE_ID",
    invoiceOrAccountNumber = "ECG-0451182",
    description = "Electricity - August",
}, idempotencyKey: $"bill:{invoice.Id}");

Note

On a bill payment, invoiceOrAccountNumber is doing double duty: it is your reference and it is the account the biller will credit. Get it from the customer and validate its shape before submitting - a typo here pays someone else's bill, and the biller, not us, controls whether that can be undone.

Inline details: pay-bill-ext

Supply the paying customer's account details directly. customerAccountType decides which customer* fields are required - E_WALLET needs customerEwalletType, customerAccountName and customerAccountNumber; CARD needs the card fields. A bank account cannot fund a bill payment: customerAccountType: BANK_ACCOUNT is rejected with 400. The wallet networks available for the payer's side are the same as for collections.

The optional merchant* fields describe the receiving side where you are not using a stored biller.

cURL

paydirect POST "/transactions/pay-bill-ext" '{
  "amountToSend": 85,
  "sendingCurrency": "GHS",
  "receivingCurrency": "GHS",
  "invoiceOrAccountNumber": "ECG-0451182",
  "customerAccountType": "E_WALLET",
  "customerEwalletType": "TELECEL",
  "customerAccountName": "Ama Serwaa",
  "customerAccountNumber": "0204000000",
  "customerEmailAddress": "ama@example.com"
}'

Node.js

const { status, body } = await payDirect.post(
  '/transactions/pay-bill-ext',
  {
    amountToSend: 85,
    sendingCurrency: 'GHS',
    receivingCurrency: 'GHS',
    invoiceOrAccountNumber: 'ECG-0451182',
    customerAccountType: 'E_WALLET',
    customerEwalletType: 'TELECEL',
    customerAccountName: 'Ama Serwaa',
    customerAccountNumber: '0204000000',
    customerEmailAddress: 'ama@example.com',
  },
  `bill:${invoice.id}`,
);

Python

status, body = post(
    "/transactions/pay-bill-ext",
    {
        "amountToSend": 85,
        "sendingCurrency": "GHS",
        "receivingCurrency": "GHS",
        "invoiceOrAccountNumber": "ECG-0451182",
        "customerAccountType": "E_WALLET",
        "customerEwalletType": "TELECEL",
        "customerAccountName": "Ama Serwaa",
        "customerAccountNumber": "0204000000",
        "customerEmailAddress": "ama@example.com",
    },
    idempotency_key=f"bill:{invoice.id}",
)

PHP

<?php
$result = (new PayDirect())->post('/transactions/pay-bill-ext', [
    'amountToSend' => 85,
    'sendingCurrency' => 'GHS',
    'receivingCurrency' => 'GHS',
    'invoiceOrAccountNumber' => 'ECG-0451182',
    'customerAccountType' => 'E_WALLET',
    'customerEwalletType' => 'TELECEL',
    'customerAccountName' => 'Ama Serwaa',
    'customerAccountNumber' => '0204000000',
    'customerEmailAddress' => 'ama@example.com',
], "bill:{$invoice->id}");

Java

String payload = """
    {
      "amountToSend": 85,
      "sendingCurrency": "GHS",
      "receivingCurrency": "GHS",
      "invoiceOrAccountNumber": "ECG-0451182",
      "customerAccountType": "E_WALLET",
      "customerEwalletType": "TELECEL",
      "customerAccountName": "Ama Serwaa",
      "customerAccountNumber": "0204000000",
      "customerEmailAddress": "ama@example.com"
    }
    """;

HttpResponse<String> response = new PayDirect().post("/transactions/pay-bill-ext", payload);

C#

var response = await new PayDirect().PostAsync("/transactions/pay-bill-ext", new
{
    amountToSend = 85,
    sendingCurrency = "GHS",
    receivingCurrency = "GHS",
    invoiceOrAccountNumber = "ECG-0451182",
    customerAccountType = "E_WALLET",
    customerEwalletType = "TELECEL",
    customerAccountName = "Ama Serwaa",
    customerAccountNumber = "0204000000",
    customerEmailAddress = "ama@example.com",
}, idempotencyKey: $"bill:{invoice.Id}");

Resolving the outcome

The first leg is a collection from the payer, so a bill payment routinely sits PENDING while the payer approves a mobile money prompt. Provider reconciliation can also keep it pending after that.

Flow Reconcile endpoint
pay-bill POST /transactions/pay-bill/{id}/reconcile
pay-bill-ext POST /transactions/pay-bill-ext/{id}/reconcile

Webhooks fire TRANSACTION_SUCCEEDED / TRANSACTION_FAILED. GET /transactions/{id}/journey-snapshot shows each leg separately, which is what you want when the collection succeeded but the settlement to the biller did not.

Caution

The terminal-status behaviour described for PAYOUT and WITHDRAWAL is not claimed for bill payments - this path has its own reconciliation logic. If your integration depends on a resolved bill payment never changing state, ask us to confirm for the specific biller you are using rather than assuming it.

Compensation

A bill payment is two legs. The collection succeeding is not the same as the bill being paid.

A two-leg journey can collect successfully and then fail to settle. When that happens the transaction enters compensation, and you may see TRANSACTION_COMPENSATION_REQUIRED followed by TRANSACTION_COMPENSATION_COMPLETED on your webhook endpoint. Handle those events by marking the payment as unresolved in your own system and waiting for the terminal outcome - do not tell the customer their bill is paid on the strength of the collection leg alone.

Next

Reference data - billers, banks, recipients and funding sources.