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:
- 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.
- Issue a replacement with
POST /integrators/{id}/apikeys/issue, using the samekeyType. - Store the whole
dataobject (apiKey,secretKeyandpublicKey) in your secret manager before you do anything else. - 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:
- Issue a new key with the same
keyTypeand scopes. - Deploy it to your services.
- Watch
GET /apikey/{oldKeyId}/usage/summaryuntil traffic on the old key reaches zero. - 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
.envfiles 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.