API Reference

Build with the SMPLY PAY API

Every endpoint below is authenticated with HMAC-signed requests — no session cookies, built for server-to-server calls. All request/response shapes and behaviors here are verified directly against the current backend implementation.

Overview

The public API (/api/v1/...) covers the same core capabilities the authenticated dashboard gives a logged-in user: deposits, withdrawals, escrow, transaction history, and webhook registration. Every /api/v1/... handler is a thin wrapper around the identical function the session-authenticated dashboard calls — there is no second, independently-maintained copy of a balance check, fee calculation, or OTP flow behind the two surfaces to drift out of sync.

A crypto deposit itself still has no “initiate” endpoint — a deposit always happens by sending crypto directly to a wallet's own address on the relevant network; nothing about that changes here. What crypto payment requests add is a way to get that address and track a specific expected payment against it programmatically, without needing the dashboard. Still not exposed via the public API: currency conversion (crypto ↔ KES) is currently session-only, with no /api/v1 equivalent — worth flagging honestly rather than documenting an endpoint that doesn't exist.

Authentication

Every request must carry four headers, and the signature covers the request's method, path, body, a timestamp, and a nonce — not just proof you possess a key, but proof you authored this exact request.

1. Get an API key

From the authenticated dashboard's Developer page (POST /developer/api-keys, session-authenticated), create a key with a label. The response includes key_id (safe to store/display) and secretshown exactly once. There is no “view secret again” endpoint; if you lose it, revoke the key and create a new one.

2. Required headers

HeaderValue
X-Api-Key-IdYour key's public key_id
X-TimestampCurrent Unix time, in seconds, as a decimal string
X-NonceA fresh random string per request (16 random bytes, hex-encoded, is what the sample client uses) — never reuse one
X-SignatureHex-encoded HMAC-SHA256 — see below for exactly what it covers

3. What gets signed

Build this exact string, newline-separated, then HMAC-SHA256 it with your secret and hex-encode the result:

Canonical string format
{timestamp}\n{method}\n{path_and_query}\n{nonce}\n{body}

# method:        HTTP method, upper case ("POST", "GET")
# path_and_query: request path including query string, no scheme/host
#                 (e.g. "/api/v1/wallets/abc-123/balance")
# body:           exact raw request body bytes, UTF-8 — empty string
#                 for a body-less GET, never "null" or omitted

The fields are joined with a plain newline (\n) — not concatenated directly — so a value containing the delimiter can never shift a later field's boundary.

4. Timestamp tolerance & replay protection

A request is only accepted if its X-Timestamp is within 300 seconds of the server's clock, in either direction (this is configurable server-side; 300s/5 minutes is the default). That alone doesn't stop a captured request being replayed within that window — a second layer does: every (key_id, nonce) pair is recorded on first use, and a request presenting a nonce that key has already used is rejected outright, regardless of how fresh its timestamp still reads. Never reuse a nonce.

Complete, correct examples

These are runnable — adapted directly from this project's own scripts/sign_and_call.py sample client, not simplified pseudocode.

Python (standard library only)
import hashlib, hmac, os, secrets, time, urllib.request

key_id = os.environ["SMPLY_API_KEY_ID"]
secret = os.environ["SMPLY_API_SECRET"]
base_url = "https://api.smply-pay.example.com"

def canonical_string(timestamp, method, path_and_query, nonce, body):
    return "\n".join([timestamp, method, path_and_query, nonce, body])

def sign(secret, message):
    return hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()

path = "/api/v1/wallets/YOUR_WALLET_ID/balance"
timestamp = str(int(time.time()))
nonce = secrets.token_hex(16)   # fresh, random, every request
body = ""                        # empty for this GET

message = canonical_string(timestamp, "GET", path, nonce, body)
signature = sign(secret, message)

request = urllib.request.Request(
    url=base_url + path,
    headers={
        "X-Api-Key-Id": key_id,
        "X-Signature": signature,
        "X-Timestamp": timestamp,
        "X-Nonce": nonce,
    },
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
cURL (with a pre-computed signature)
TIMESTAMP=$(date +%s)
NONCE=$(openssl rand -hex 16)
BODY=""
MESSAGE="$TIMESTAMP\nGET\n/api/v1/wallets/YOUR_WALLET_ID/balance\n$NONCE\n$BODY"
SIGNATURE=$(printf '%s' "$MESSAGE" | openssl dgst -sha256 -hmac "$SMPLY_API_SECRET" -hex | sed 's/^.* //')

curl "https://api.smply-pay.example.com/api/v1/wallets/YOUR_WALLET_ID/balance" \
  -H "X-Api-Key-Id: $SMPLY_API_KEY_ID" \
  -H "X-Signature: $SIGNATURE" \
  -H "X-Timestamp: $TIMESTAMP" \
  -H "X-Nonce: $NONCE"

A full, dependency-free reference client (Python standard library only) also lives at scripts/sign_and_call.py in this project's own repository — copy it directly rather than re-implementing signing from scratch.

Endpoints

Deposits

POST/api/v1/wallets/:wallet_id/deposits/stk

Requires View-level access to the wallet. Triggers an M-Pesa STK push prompt to the given phone number.

Request body
{
  "phone_number": "254712345678",
  "amount": "500"
}
Response — 201 Created
{
  "id": "e2add425-10c7-45ab-8931-a9698c2f2365",
  "wallet_id": "43d71324-0af8-4efa-8211-c43cb9c47423",
  "status": "pending",
  "checkout_request_id": "ws_CO_...",
  "merchant_request_id": "29115-...",
  "amount": "500",
  "phone_number": "254712345678",
  "result_description": null,
  "created_at": "2026-08-31T12:00:00Z",
  "updated_at": "2026-08-31T12:00:00Z"
}

status is one of pending, succeeded, failed — the deposit settles asynchronously once M-Pesa confirms it; poll GET /api/v1/transactions?wallet_id=... or listen for the deposit.settled webhook rather than polling this specific resource (there is no GET equivalent for a single STK deposit in the public API).

Wallet balance

GET/api/v1/wallets/:wallet_id/balance

Requires View-level access. Returns every currency this platform supports — all six, KES plus all five crypto networks — regardless of whether the wallet actually holds a non-zero balance in each one; confirmed directly against a real, freshly-created wallet, which returns six rows all reading "0", not an empty array or a filtered list. Crypto currencies are identified as "{network}:{symbol}" (e.g. "ethereum:ETH", "polygon:POL") — not the bare symbol alone, since the same symbol could in principle exist on more than one network.

Response — 200 OK (a wallet with a KES deposit, everything else still zero)
[
  { "currency": "KES", "amount": "500.000000000000000000" },
  { "currency": "celo:CELO", "amount": "0" },
  { "currency": "ethereum:ETH", "amount": "0" },
  { "currency": "optimism:OP", "amount": "0" },
  { "currency": "polygon:POL", "amount": "0" },
  { "currency": "solana:SOL", "amount": "0" }
]

Amounts are full-precision decimal strings, not floats — every non-zero amount on this page carries the underlying column's full NUMERIC(38,18) scale (e.g. "500.000000000000000000", not "500"), confirmed directly against live responses. Other examples on this page are shown at reduced precision for readability — parse them as decimals, never as JSON numbers/floats, either way.

Withdrawals

POST/api/v1/wallets/:wallet_id/withdrawals

Requires Manage-level access. destination_type is a tag that decides which other fields are required — phone, paybill, buy_goods, or crypto.

Request body — KES to M-Pesa phone
{
  "amount": "500",
  "destination_type": "phone",
  "phone_number": "0712345678"
}
Request body — crypto
{
  "amount": "0.1",
  "destination_type": "crypto",
  "network": "ethereum",
  "address": "0xAbC123...",
  "token_symbol": null
}
Response — 201 Created (a real, live-captured response)
{
  "id": "430d3909-8400-432c-bb03-dc8082866508",
  "wallet_id": "43d71324-0af8-4efa-8211-c43cb9c47423",
  "status": "pending",
  "currency": "KES",
  "amount": "200.000000000000000000",
  "fee_amount": "4.000000000000000000",
  "net_amount": "196.000000000000000000",
  "destination_type": "phone",
  "destination_description": "phone 254712345678",
  "destination_caveat": null,
  "provider_reference": null,
  "result_description": null,
  "created_at": "2026-08-31T19:30:34Z",
  "updated_at": "2026-08-31T19:30:34Z",
  "settled_at": null
}

A newly-created withdrawal is pending — it does not move funds until confirmed with an OTP (below). destination_description shows the phone number normalized to international format (254...) regardless of which format you submitted it in (0712.../254712.../+254712... are all accepted on the way in — see core::validation::normalize_kenyan_phone_number). destination_caveat is only ever set for a crypto destination: address format was valid, which is not the same as a guarantee of correctness.

POST/api/v1/withdrawals/:withdrawal_id/confirm-otp

The one-time code is delivered by email (out of band — the public API has no endpoint that returns it). Confirming this request debits the wallet and attempts the payout in the same call — the response already reflects the terminal state (settled or failed), not an intermediate one to poll for.

Request body
{ "code": "123456" }

Response: the same WithdrawalView shape as above, with status now settled or failed.

GET/api/v1/withdrawals/:withdrawal_id

Requires Manage-level access to the withdrawal's own wallet. Returns the same WithdrawalView shape — the way to poll a crypto withdrawal's progress through processing/broadcasting to settled/failed.

Transaction history

GET/api/v1/transactions?wallet_id=... (optional)

With wallet_id: recent ledger entries for that one wallet (requires View-level access). Without it: a genuine aggregation across every wallet you belong to, newest first — the one endpoint with no single-wallet session-dashboard equivalent to wrap.

Response — 200 OK
[
  {
    "id": "f1a2...",
    "wallet_id": "43d71324-0af8-4efa-8211-c43cb9c47423",
    "entry_type": "stk_deposit",
    "currency": "KES",
    "amount": "500",
    "created_at": "2026-08-31T12:00:00Z"
  }
]

Escrow

POST/api/v1/wallets/:wallet_id/escrows

:wallet_id is the depositor's funding wallet — requires Manage-level access. manager_wallet_id is optional; omit it and the platform itself arbitrates any dispute.

Request body
{
  "receiver_wallet_id": "b8f3...",
  "manager_wallet_id": null,
  "currency": "KES",
  "amount": "10000",
  "commission_rate_percentage": "2.5"
}
Response — 201 Created
{
  "id": "c4d5...",
  "depositor_wallet_id": "43d71324-0af8-4efa-8211-c43cb9c47423",
  "receiver_wallet_id": "b8f3...",
  "manager_wallet_id": null,
  "currency": "KES",
  "amount": "10000",
  "commission_rate_percentage": "2.5",
  "receiver_amount": "9750",
  "commission_amount": "250",
  "status": "funded",
  "caller_roles": ["depositor"],
  "confirmations": [],
  "result_description": null,
  "created_at": "2026-08-31T12:00:00Z",
  "updated_at": "2026-08-31T12:00:00Z",
  "released_at": null,
  "disputed_at": null,
  "refunded_at": null
}
GET/api/v1/escrows

Every escrow you're a party to (as depositor, receiver, or manager) — an array of the same EscrowView shape above.

GET/api/v1/escrows/:escrow_id

One escrow's full current state.

POST/api/v1/escrows/:escrow_id/confirm

confirmation_type decides which side you're confirming as, and which wallet's access is checked: "depositor" requires Manage on the depositor wallet, "receiver" requires it on the receiver wallet. Confirming does not release funds by itself — verified directly against the live API, not assumed: after both confirmations are present the escrow's status is awaiting_confirmation, still unreleased, until POST .../release (below) is called explicitly.

Request body
{ "confirmation_type": "receiver" }
POST/api/v1/escrows/:escrow_id/release

No request body. Only succeeds once both required confirmations are present — but is a real, separate call your integration must make; nothing releases funds automatically just because both sides confirmed. Any party (depositor, receiver, or manager) may call it — by the time it can succeed, the two primary parties have already mutually agreed via their own confirmations.

POST/api/v1/escrows/:escrow_id/dispute

No request body. Moves the escrow to disputed, pending resolution.

POST/api/v1/escrows/:escrow_id/resolve-dispute

Requires Manage-level access to the manager wallet (or, if none was named, super-admin platform access). resolution is "release" or "refund".

Request body
{ "resolution": "release" }

Crypto payment requests

A trackable “I'm expecting a crypto payment for this amount/reference” object — built for accepting crypto payments programmatically (your own checkout flow, or another business system) rather than watching a wallet's balance by hand. Why this exists on top of a wallet's own deposit address: a wallet's crypto deposit address is reused for its entire lifetime (one address per key family — the same address covers Ethereum, Polygon, Celo, and Optimism at once), so a bare address alone can't tell two payments you're expecting at the same time apart. A payment request gives each expected payment its own trackable record, resolved automatically once a matching deposit confirms.

POST/api/v1/crypto/payment-requests

Requires Manage-level access to wallet_id. token_symbol omitted or null means the network's native currency. expected_amount omitted or null means any amount is accepted — such a request always resolves to received, never confirmed/underpaid/overpaid, since there's nothing to compare the paid amount against. expiry_minutes defaults to 30 if omitted, and must be between 5 and 10080 (one week) — confirmed directly against a live request that omitted it entirely.

Request body
{
  "wallet_id": "b7c3e0b6-6fd6-4cc9-b311-1f08c3717fed",
  "network": "ethereum",
  "token_symbol": null,
  "expected_amount": "0.05",
  "reference": "order-9F2K",
  "expiry_minutes": 60
}
Response — 201 Created (a real, live-captured response)
{
  "id": "b79c0d3c-0369-43d8-aa00-a9953834243a",
  "wallet_id": "b7c3e0b6-6fd6-4cc9-b311-1f08c3717fed",
  "network": "ethereum",
  "currency": "ethereum:ETH",
  "expected_amount": "0.050000000000000000",
  "reference": "order-9F2K",
  "status": "pending",
  "matched_deposit_ledger_entry_id": null,
  "deposit_address": "0xd0510003A76Eebff3948219902D21F345BE38521",
  "expires_at": "2026-09-01T14:51:00.224738Z",
  "created_at": "2026-09-01T13:51:00.225528Z",
  "updated_at": "2026-09-01T13:51:00.225528Z"
}

Show deposit_address to the payer, and either poll GET .../payment-requests/:id or listen for the payment_request.* webhooks (below) for resolution. status starts pending and moves to exactly one of received, confirmed, underpaid, overpaid, or expired — never back to pending, and never a second time once it's left pending.

GET/api/v1/crypto/payment-requests/:id

Requires View-level access to the request's own wallet. Returns the same shape as the create response above, with the current status and, once resolved, matched_deposit_ledger_entry_id set.

GET/api/v1/crypto/payment-requests?reference=...

Look a request up by the reference you supplied at creation, instead of storing SMPLY PAY's own id. Not unique — an array, since nothing stops (or should stop) reusing a reference across a retried or expired request. Only returns requests on wallets you actually have access to, even if another account happens to reuse the identical reference string.

Response — 200 OK (a real, live-captured response)
[
  {
    "id": "b79c0d3c-0369-43d8-aa00-a9953834243a",
    "wallet_id": "b7c3e0b6-6fd6-4cc9-b311-1f08c3717fed",
    "network": "ethereum",
    "currency": "ethereum:ETH",
    "expected_amount": "0.050000000000000000",
    "reference": "order-9F2K",
    "status": "pending",
    "matched_deposit_ledger_entry_id": null,
    "deposit_address": "0xd0510003A76Eebff3948219902D21F345BE38521",
    "expires_at": "2026-09-01T14:51:00.224738Z",
    "created_at": "2026-09-01T13:51:00.225528Z",
    "updated_at": "2026-09-01T13:51:00.225528Z"
  }
]

On matching more than one open request at once, stated honestly: an incoming deposit is matched by amount (this platform's chain integrations carry no memo/tag field to disambiguate by anything else). An exact amount match always wins; if you open more than one concurrent request for the same wallet/currency, give each a distinct expected_amount (a unique trailing decimal digit is enough) to guarantee an incoming payment can never be mismatched between them.

Webhook registration

POST/api/v1/webhooks

Registers a callback URL for your account. The URL is validated against a public, non-private address at registration time (and again immediately before every delivery, since DNS can change in between).

Request body
{ "url": "https://your-service.example.com/webhooks/smply-pay" }
Response — 201 Created
{
  "id": "d6e7...",
  "url": "https://your-service.example.com/webhooks/smply-pay",
  "secret": "shown-exactly-once...",
  "created_at": "2026-08-31T12:00:00Z"
}

secret is shown exactly once, the same as an API key's own secret — see Verifying deliveries for what it's for.

Webhooks

Events & payloads

Every registered endpoint receives a delivery for each of these events, fanned out per event to every endpoint you've registered.

Event (X-Event-Type)Fires when
deposit.settledA deposit (KES or crypto) credits a wallet
withdrawal.settledA withdrawal's payout succeeds
withdrawal.failedA withdrawal's payout fails and its debit is reversed
escrow.releasedAn escrow releases (both confirmations, or a dispute resolved to release)
escrow.dispute_resolvedA disputed escrow is resolved (release or refund)
payment_request.receivedA matching deposit confirmed for a request with no expected_amount set
payment_request.confirmedA matching deposit confirmed for exactly the request's expected_amount
payment_request.underpaidA matching deposit confirmed, but for less than expected_amount
payment_request.overpaidA matching deposit confirmed, but for more than expected_amount
payment_request.expiredA request's expires_at passed with no matching deposit ever observed
deposit.settled
{
  "wallet_id": "43d71324-0af8-4efa-8211-c43cb9c47423",
  "entry_type": "stk_deposit",
  "amount": "500",
  "currency": "KES",
  "new_balance": "13000"
}
withdrawal.settled / withdrawal.failed
{
  "wallet_id": "43d71324-0af8-4efa-8211-c43cb9c47423",
  "amount": "500",
  "currency": "KES",
  "destination": "phone 254712345678",
  "new_balance": "12500"
}
escrow.released / escrow.dispute_resolved
{
  "escrow_id": "c4d5...",
  "status": "released",
  "result_description": null
}
payment_request.received / .confirmed / .underpaid / .overpaid (a real, live-captured payload — .confirmed shown)
{
  "payment_request_id": "f50a4e9e-4afd-46ab-b78a-4f0b86bc2e83",
  "wallet_id": "b7c3e0b6-6fd6-4cc9-b311-1f08c3717fed",
  "status": "confirmed",
  "paid_amount": "0.05",
  "currency": "ethereum:ETH",
  "ledger_entry_id": "cc2db2c5-cb8a-4ead-b1b5-fbe0104e0002"
}
payment_request.expired
{
  "payment_request_id": "f50a4e9e-4afd-46ab-b78a-4f0b86bc2e83",
  "wallet_id": "b7c3e0b6-6fd6-4cc9-b311-1f08c3717fed",
  "status": "expired"
}

Delivered to the API key owner who created the request specifically — unlike deposit.settled/withdrawal.*/escrow.* above, which fan out to every Manage-level member of the wallet involved, a payment request is your own integration's object from the moment you create it, so only your own registered endpoint(s) hear about it.

Verifying deliveries

Yes — outbound webhook deliveries are genuinely signed, using the identical HMAC scheme inbound API requests use (same canonical-string format, same headers), so you can verify a delivery actually came from SMPLY PAY rather than trusting the source IP or TLS alone. This is not a hypothetical/planned feature — it's the current implementation, confirmed directly in crates/api/src/webhooks.rs.

HeaderMeaning
X-SignatureHMAC-SHA256(webhook secret, canonical string), hex-encoded
X-TimestampUnix seconds, at send time
X-NonceThe delivery's own id (a UUID) — unique per delivery, reused across retry attempts of the same delivery
X-Event-TypeOne of the event names above

Recompute the signature the same way inbound requests are verified — canonical string {timestamp}\nPOST\n{path}\n{nonce}\n{body}, HMAC-SHA256 with your webhook secret (from the registration response, shown once), hex-encoded — and compare it to X-Signature using a constant-time comparison, not ==.

Retries & backoff

A delivery that fails (non-2xx response, or a transport-level error) is retried with exponential backoff — 30s, 1m, 2m, 4m, 8m — up to 6 attempts total. If the 6th attempt also fails, the delivery is dead-lettered (no further retries) roughly 15 minutes after the first attempt. Your endpoint should be idempotent per delivery (keyed by the delivery's X-Nonce) in case a retry succeeds on your end but the response is lost in transit.

Reference

Error responses

Every non-2xx response has the same shape:

Error body
{ "status": "error", "message": "human-readable description" }
StatusMeaning
422Well-formed request, but a validation rule failed (bad amount, unknown destination type, ...)
401Missing/invalid signature, unknown key, expired timestamp, or a replayed nonce
403Authenticated, but you don't hold the required access level on the resource
404The resource doesn't exist, or you have no relationship to it
429Rate limited — see below
503The service is temporarily unable to reach its database
500An unexpected server-side error

Rate limits

ScopeLimit
Every request, per API key120 requests / minute
Withdrawal initiation, per wallet10 / 15 minutes
Withdrawal OTP confirmation, per withdrawal5 attempts / 5 minutes (matches the OTP's own expiry)
Escrow creation, per wallet20 / 15 minutes
Escrow release / dispute / resolve, per escrow30 / 15 minutes
Crypto payment request creation, per wallet30 / 15 minutes

A rate-limited request returns 429 with the standard error shape above.

Looking to use the product directly instead of integrating with it? See the User Guide.