Skip to content
PayDirect Docs

Docs › Getting started

API keys and credentials

Getting your first key, issuing more yourself, scoping them to only what you need, and rotating safely.

An API key belongs to an Integrator - your tenant on the platform. One Integrator can hold many keys: typically one per key type - test, sandbox and production - and often one per service, so a compromise is contained and revocable in isolation.

Getting your first key

Your first key is issued by PayDirect during onboarding, because there is no key yet to authenticate a self-service request with. You will be handed:

  • the public key (pk_test_… / pk_live_…),
  • the raw API key,
  • the secret key (sk_test_… / sk_live_…),
  • your Integrator id, which appears in several endpoint paths below,
  • and, if you want webhooks, a webhook signing secret.

Caution

The secret key and the webhook signing secret are returned once, in the response that creates them. We do not show them again. The raw API key is returned in full by key listings, but store it with the others anyway. If you lose one, the only remedy is to issue a replacement and revoke the old one - see If you lose a credential.

Issuing further keys yourself

Once you hold one working key, you can manage the rest without involving us:

Endpoint What it does
POST /integrators/{id}/apikeys/issue Issue another key
GET /integrators/{id}/apikeys List your keys, active and revoked, with their keyType
PATCH /integrators/{id}/apikeys/{keyId} Update a key's name, keyType (SANDBOX ↔ PRODUCTION only) or dateExpires
PATCH /apikey/revoke-my-key/{id} Revoke a key that was issued to you
GET /apikey/{id}/usage/summary Request totals, error rate and latency for a key
GET /apikey/{id}/usage/timeseries The same, over time
GET /apikey/{id}/usage/events Raw per-request usage events
GET /integrators/{id}/usage/summary Totals across all of your keys
GET /integrators/{id}/usage/timeseries The same, over time

The usage endpoints accept endpoint, statusCode, authOutcome and environment filters. authOutcome is how you find refused requests - for example IP_NOT_ALLOWED or BAD_SIGNATURE.

Webhook and callback URLs are set once for your whole Integrator, not per key. See Webhooks overview and 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 /integrators/$INTEGRATOR_ID/apikeys/issue '{
  "userId": "…",
  "name": "checkout-service (live)",
  "keyType": "PRODUCTION"
}'

Node.js

const { body } = await payDirect.post(`/integrators/${integratorId}/apikeys/issue`, {
  userId,
  name: 'checkout-service (live)',
  keyType: 'PRODUCTION',
});

// The secret key is returned only here. Write all three straight to your secret manager.
await secrets.store(body.data);

Python

status, body = post(f"/integrators/{integrator_id}/apikeys/issue", {
    "userId": user_id,
    "name": "checkout-service (live)",
    "keyType": "PRODUCTION",
})

# The secret key is returned only here. Write all three straight to your secret manager.
secrets.store(body["data"])

PHP

<?php
$result = (new PayDirect())->post("/integrators/{$integratorId}/apikeys/issue", [
    'userId'  => $userId,
    'name'    => 'checkout-service (live)',
    'keyType' => 'PRODUCTION',
]);

// The secret key is returned only here. Write all three straight to your secret manager.
$secrets->store($result['body']['data']);

Java

HttpResponse<String> response = new PayDirect().post(
        "/integrators/" + integratorId + "/apikeys/issue",
        """
        { "userId": "…", "name": "checkout-service (live)", "keyType": "PRODUCTION" }
        """);
// The secret key is returned only here. Write all three straight to your secret manager.

C#

var response = await new PayDirect().PostAsync($"/integrators/{integratorId}/apikeys/issue", new
{
    userId,
    name    = "checkout-service (live)",
    keyType = "PRODUCTION",
});
// The secret key is returned only here. Write all three straight to your secret manager.

The userId must be your Integrator's owner or an active member. The response carries the raw key (apiKey), the publicKey and the secretKey - capture all three there and then. A key is unusable without the raw key and the secret key, so saving only one of them is the same as losing the key. It also echoes the stored name and keyType, so you can see what was applied when you omitted either.

keyType is optional. When omitted, a verified Integrator gets a PRODUCTION key and an unverified one gets SANDBOX. It decides what the key does with money and which prefix it gets:

keyType Prefix Money
DEVELOPMENT pk_test_… Always mocked, any amount
SANDBOX pk_live_… Real, at most GHS 1.00 per transaction
PRODUCTION pk_live_… Real; GHS 1.00 per transaction until your Integrator is verified

See Environments and key types for the detail. A key's type can later be changed between SANDBOX and PRODUCTION, but never to or from DEVELOPMENT - the prefix is part of the credentials, so issue a new key instead. Trying returns 400.

A newly issued key carries the same scopes as the keys PayDirect issued you. It does not copy the scopes of the key you used to issue it.

If you lose a credential

What you can and cannot get back:

Credential Recoverable?
Public key Yes - GET /integrators/{id}/apikeys returns it in full for every key
Raw API key Yes - listings return it in full
Secret key No - listings show only the first and last five characters
Webhook signing secret No - ask PayDirect for a new one, see Support

The public key is only an identifier. Signing needs the raw API key and the secret key, so a key missing its secret key cannot make a request. To recover:

  1. Get a working session. If another key of yours still works, use it. If every key is revoked or incomplete, sign in to the dashboard as your Integrator's owner or an active member - a dashboard session is not signed with a key, and is not subject to your IP allowlist.
  2. Issue a replacement with POST /integrators/{id}/apikeys/issue, using the same keyType.
  3. Store the whole data object (apiKey, secretKey and publicKey) in your secret manager before you do anything else.
  4. Revoke the incomplete key with PATCH /apikey/revoke-my-key/{id}. Issuing does not revoke your other keys, so an unusable key stays active until you do. Only the person a key was issued to can revoke it this way; for anyone else's key, ask PayDirect.

If you cannot sign in and no key works, email support@softmastersgroup.com with your Integrator id and the environment. Do not include any credential.

Scopes

A key can be limited to a subset of capabilities. Available scopes:

Scope Grants
TRANSACTIONS_READ Reading transactions, details, snapshots and receipts; charge quotes; your Integrator's configured charges and limits
TRANSACTIONS_WRITE Send-money and bill-payment writes
COLLECTIONS_WRITE POST /transactions/collect
PAYOUTS_WRITE Disbursement to an arbitrary recipient
WITHDRAWALS_WRITE Withdrawal to your own pre-registered settlement account
CHECKOUT_WRITE Generating hosted checkout payment links
CHECKOUT_READ Reading a checkout session's status
REQUEST_MONEY_READ / REQUEST_MONEY_WRITE Reserved for Request Money, which is not generally available yet
WEBHOOKS_MANAGE The webhook delivery log and manual redelivery (/webhooks/*)
SUBACCOUNTS_READ / SUBACCOUNTS_WRITE Split payments: reading, and creating or changing, subaccounts and split groups, and settling a subaccount on demand
COMMISSIONS_READ / COMMISSIONS_WRITE Integrator commissions: reading your commission schedules, earnings and reports, and setting your commission schedules

Keys PayDirect issues to you carry every scope in this table except SUBACCOUNTS_READ, SUBACCOUNTS_WRITE, COMMISSIONS_READ and COMMISSIONS_WRITE, which are added on request: SUBACCOUNTS_WRITE decides where part of your collections is paid out, and COMMISSIONS_WRITE changes what every one of your payers is charged. If you want a key limited to less, ask PayDirect to narrow it.

The useful distinction is PAYOUTS_WRITE versus WITHDRAWALS_WRITE. A payout goes to whatever recipient the request names; a withdrawal can only ever reach the settlement account you registered with us in advance. A collect-then-settle integration should hold COLLECTIONS_WRITE + WITHDRAWALS_WRITE and deliberately not PAYOUTS_WRITE - then a leaked key cannot send your balance to a stranger's wallet.

Scope changes are made by PayDirect, not self-service, for exactly that reason: a compromised key must not be able to widen its own permissions.

Narrowed keys cannot manage configuration

A key narrowed to fewer scopes than it was issued with can move money within its scopes, but it cannot change your Integrator's configuration. Otherwise it could issue itself a new key with wider scopes, or redirect your webhooks. These calls are refused with 403 for a narrowed key:

  • issuing and updating keys,
  • setting the webhook URL or callback URL,
  • adding or removing allowlisted IPs.

Make those changes from the dashboard, or with a key that has not been narrowed.

Rotating a key

Keys are additive, so rotation needs no downtime:

  1. Issue a new key with the same keyType and scopes.
  2. Deploy it to your services.
  3. Watch GET /apikey/{oldKeyId}/usage/summary until traffic on the old key reaches zero.
  4. Revoke the old key with PATCH /apikey/revoke-my-key/{oldKeyId}. Only the person a key was issued to can revoke it this way; for anyone else's key, ask PayDirect.

Rotate on a schedule, and immediately on any suspicion - a key that appeared in a log, a screenshot, a support ticket or a repository is compromised whether or not anyone used it.

A revoked key is never deleted: its history stays attached to the transactions it made, so your audit trail remains complete. Requests presenting it get 401 API key has been revoked, and the attempt is recorded as a security event.

Storing credentials

  • Keep the raw key and secret key in a secret manager, not in .env files committed anywhere.
  • Never log them, not even truncated - a prefix plus a length is enough to help an attacker.
  • Never ship them to a browser or mobile app. Signing happens on your server; a key in client code is public the moment it ships.
  • Use separate keys per environment and, where practical, per service.

Next

IP allowlisting - restrict which addresses may use your keys.