Guide · 02 / 10
Authentication
API keys, HMAC request signing with a worked example, scopes, and replay protection.
Every request to the public API is authenticated with a merchant API key and an HMAC-SHA256 signature computed over the request. Three headers are always required:
| Header | Value |
|---|---|
X-WasaaPay-Key | Your full API key, wp_<prefix>.<secret>. |
X-WasaaPay-Timestamp | The current time as Unix epoch seconds, e.g. 1734600000. |
X-WasaaPay-Signature | Hex HMAC-SHA256 over `${timestamp}.${rawBody}`, keyed by the key's secret. |
If any header is missing the gateway responds 401 AUTH_INVALID_KEY; if the
signature does not match, 401 AUTH_SIGNATURE_MISMATCH.
Obtaining API keys
API keys are issued from the WasaaPay Dashboard under Developers → API
keys, which calls the identity service's POST /v1/api-keys endpoint on your
behalf (a session-authenticated dashboard surface — it is not reachable through
the public HMAC gateway). The response contains the full credential exactly
once:
{
"api_key": "wp_9f2c1a7be3d0.ZmFrZS1zYW5kYm94LXNlY3JldC1kb25vdC11c2U",
"id": "b6f0…",
"key_prefix": "wp_9f2c1a7be3d0",
"scope": "write",
"status": "active",
"expires_at": null
}
Store api_key securely — only an argon2id hash of the secret is kept
server-side, so it can never be shown again. If you lose it, rotate the key.
Self-serve signup is not built yet. Today, merchant accounts and their first dashboard user are provisioned by WasaaPay. Once you have dashboard access you can manage keys yourself.
Key anatomy
A key is a single string with two parts separated by the first .:
wp_9f2c1a7be3d0 . ZmFrZS1zYW5kYm94LXNlY3JldC1kb25vdC11c2U
└──── prefix ────┘ └──────────────── secret ──────────────┘
- Send the whole string as
X-WasaaPay-Key. - The secret — everything after the first
.— is the HMAC key you sign requests with. Keep the two conceptually separate in your code: the part after the dot is what you feed tocreateHmac.
Key lifecycle
Keys support rotation and revocation (also via the dashboard / identity service):
- Rotate — atomically revokes the current key and issues a successor with the same scope and IP allowlist, returning the new secret once.
- Revoke — permanently disables a key. Revoked or expired keys fail with
401 AUTH_INVALID_KEY. - A key may optionally carry an expiry and an IP allowlist; requests from
a non-allowlisted source IP are rejected as
AUTH_INVALID_KEYwithdetails.reason = "ip_not_allowed".
Scopes
Each key has one scope. The gateway classifies every request into an operation
class — read, write, or payout — and rejects it if the key's scope does not
permit that class.
| Scope | Permits | Use it for |
|---|---|---|
read_only | read | Dashboards, reporting, polling transaction status. |
write | read + write | Collections, refunds, sub-merchants, webhooks, split rules — everything except payouts. |
payout_only | payout | An isolated key that can only move money out (bulk disbursement workers). |
full | read + write + payout | Everything. |
Operation classes map to endpoints as follows:
payout— anything under/v1/payouts(both reads and writes).read— any otherGET/HEAD.write— any other mutating method (POST,PATCH,DELETE).
A scope violation currently returns 401 AUTH_INVALID_KEY with
details.reason = "insufficient_scope" (a dedicated "insufficient scope" code is
a known gap in the error catalog).
HMAC request signing
The signed message is exactly:
{timestamp}.{rawBody}
where rawBody is the exact bytes of the request body you send on the wire
(for a GET or any body-less request, use the empty string ""). Serialize your
JSON once and sign and send those same bytes — if you re-stringify the object
you may change key order or whitespace and the signature will not match. The
signature is the lowercase hex HMAC-SHA256 digest.
This is the same algorithm implemented in libs/common/src/crypto/hmac.ts
(computeSignature) and verified by the identity service.
Worked example
Given this key, timestamp, and body:
-
Full key (
X-WasaaPay-Key):wp_9f2c1a7be3d0.ZmFrZS1zYW5kYm94LXNlY3JldC1kb25vdC11c2U -
Signing secret (part after the first
.):ZmFrZS1zYW5kYm94LXNlY3JldC1kb25vdC11c2U -
Timestamp (
X-WasaaPay-Timestamp):1734600000 -
Raw body (sent verbatim):
{"amount":150000,"currency":"KES","rail":"mobile_money","payer_phone":"254700000001","reference":"order_10293"}
The signed message is:
1734600000.{"amount":150000,"currency":"KES","rail":"mobile_money","payer_phone":"254700000001","reference":"order_10293"}
and the resulting X-WasaaPay-Signature is:
93fe33862e7c995247a2ab2630ea7095c0591113f09fc26549ce1619128008de
The secret above is a made-up sandbox placeholder, not a real credential. Run the snippet below with it and you will reproduce exactly that signature.
Node.js signer
import { createHmac } from 'node:crypto';
/**
* Build the three auth headers for a WasaaPay request.
* @param {string} apiKey Full key, "wp_<prefix>.<secret>".
* @param {string} rawBody Exact request body bytes ("" for GET / no body).
*/
function signRequest(apiKey, rawBody) {
const secret = apiKey.slice(apiKey.indexOf('.') + 1); // part after the first dot
const timestamp = String(Math.floor(Date.now() / 1000));
const signature = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`, 'utf8')
.digest('hex');
return {
'X-WasaaPay-Key': apiKey,
'X-WasaaPay-Timestamp': timestamp,
'X-WasaaPay-Signature': signature,
'Content-Type': 'application/json',
};
}
// Reproduce the worked example exactly:
const secret = 'ZmFrZS1zYW5kYm94LXNlY3JldC1kb25vdC11c2U';
const body = JSON.stringify({
amount: 150000, currency: 'KES', rail: 'mobile_money',
payer_phone: '254700000001', reference: 'order_10293',
});
console.log(createHmac('sha256', secret).update(`1734600000.${body}`).digest('hex'));
// → 93fe33862e7c995247a2ab2630ea7095c0591113f09fc26549ce1619128008de
Putting it together (curl)
BODY='{"amount":150000,"currency":"KES","rail":"mobile_money","payer_phone":"254700000001","reference":"order_10293"}'
KEY='wp_9f2c1a7be3d0.ZmFrZS1zYW5kYm94LXNlY3JldC1kb25vdC11c2U'
SECRET="${KEY#*.}" # everything after the first dot
TS=$(date +%s)
SIG=$(printf '%s' "${TS}.${BODY}" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -sS https://sandbox.api.wasaapay.com/v1/collections \
-H "X-WasaaPay-Key: $KEY" \
-H "X-WasaaPay-Timestamp: $TS" \
-H "X-WasaaPay-Signature: $SIG" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
--data "$BODY"
Timestamp skew (replay protection)
X-WasaaPay-Timestamp must be within ±300 seconds (5 minutes) of server time.
Requests outside that window are rejected as AUTH_SIGNATURE_MISMATCH even if the
HMAC is otherwise correct. This bounds how long a captured request can be
replayed. Keep your server clock in sync (NTP). The signature comparison itself is
constant-time.
Checkout-session (payer) authorization
The hosted-checkout payer endpoints (GET /checkout-sessions/{id} and
POST /checkout-sessions/{id}/pay) are the one exception to the scheme above.
They are authorized by the session's opaque client_token capability secret —
supplied as the X-Checkout-Token header or a client_token query parameter —
never by your merchant API key. The payer never holds merchant credentials. See
Collections → Hosted checkout.