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:

HeaderValue
X-WasaaPay-KeyYour full API key, wp_<prefix>.<secret>.
X-WasaaPay-TimestampThe current time as Unix epoch seconds, e.g. 1734600000.
X-WasaaPay-SignatureHex 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 to createHmac.

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_KEY with details.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.

ScopePermitsUse it for
read_onlyreadDashboards, reporting, polling transaction status.
writeread + writeCollections, refunds, sub-merchants, webhooks, split rules — everything except payouts.
payout_onlypayoutAn isolated key that can only move money out (bulk disbursement workers).
fullread + write + payoutEverything.

Operation classes map to endpoints as follows:

  • payout — anything under /v1/payouts (both reads and writes).
  • read — any other GET/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.