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 secret — shown exactly once. There is no “view secret again” endpoint; if you lose it, revoke the key and create a new one.
2. Required headers
| Header | Value |
|---|---|
X-Api-Key-Id | Your key's public key_id |
X-Timestamp | Current Unix time, in seconds, as a decimal string |
X-Nonce | A fresh random string per request (16 random bytes, hex-encoded, is what the sample client uses) — never reuse one |
X-Signature | Hex-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:
{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 omittedThe 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.
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())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
Requires View-level access to the wallet. Triggers an M-Pesa STK push prompt to the given phone number.
{
"phone_number": "254712345678",
"amount": "500"
}{
"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
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.
[
{ "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
Requires Manage-level access. destination_type is a tag that decides which other fields are required — phone, paybill, buy_goods, or crypto.
{
"amount": "500",
"destination_type": "phone",
"phone_number": "0712345678"
}{
"amount": "0.1",
"destination_type": "crypto",
"network": "ethereum",
"address": "0xAbC123...",
"token_symbol": null
}{
"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.
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.
{ "code": "123456" }Response: the same WithdrawalView shape as above, with status now settled or failed.
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
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.
[
{
"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
: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.
{
"receiver_wallet_id": "b8f3...",
"manager_wallet_id": null,
"currency": "KES",
"amount": "10000",
"commission_rate_percentage": "2.5"
}{
"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
}Every escrow you're a party to (as depositor, receiver, or manager) — an array of the same EscrowView shape above.
One escrow's full current state.
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.
{ "confirmation_type": "receiver" }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.
No request body. Moves the escrow to disputed, pending resolution.
Requires Manage-level access to the manager wallet (or, if none was named, super-admin platform access). resolution is "release" or "refund".
{ "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.
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.
{
"wallet_id": "b7c3e0b6-6fd6-4cc9-b311-1f08c3717fed",
"network": "ethereum",
"token_symbol": null,
"expected_amount": "0.05",
"reference": "order-9F2K",
"expiry_minutes": 60
}{
"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.
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.
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.
[
{
"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
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).
{ "url": "https://your-service.example.com/webhooks/smply-pay" }{
"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.settled | A deposit (KES or crypto) credits a wallet |
withdrawal.settled | A withdrawal's payout succeeds |
withdrawal.failed | A withdrawal's payout fails and its debit is reversed |
escrow.released | An escrow releases (both confirmations, or a dispute resolved to release) |
escrow.dispute_resolved | A disputed escrow is resolved (release or refund) |
payment_request.received | A matching deposit confirmed for a request with no expected_amount set |
payment_request.confirmed | A matching deposit confirmed for exactly the request's expected_amount |
payment_request.underpaid | A matching deposit confirmed, but for less than expected_amount |
payment_request.overpaid | A matching deposit confirmed, but for more than expected_amount |
payment_request.expired | A request's expires_at passed with no matching deposit ever observed |
{
"wallet_id": "43d71324-0af8-4efa-8211-c43cb9c47423",
"entry_type": "stk_deposit",
"amount": "500",
"currency": "KES",
"new_balance": "13000"
}{
"wallet_id": "43d71324-0af8-4efa-8211-c43cb9c47423",
"amount": "500",
"currency": "KES",
"destination": "phone 254712345678",
"new_balance": "12500"
}{
"escrow_id": "c4d5...",
"status": "released",
"result_description": null
}{
"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_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.
| Header | Meaning |
|---|---|
X-Signature | HMAC-SHA256(webhook secret, canonical string), hex-encoded |
X-Timestamp | Unix seconds, at send time |
X-Nonce | The delivery's own id (a UUID) — unique per delivery, reused across retry attempts of the same delivery |
X-Event-Type | One 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:
{ "status": "error", "message": "human-readable description" }| Status | Meaning |
|---|---|
| 422 | Well-formed request, but a validation rule failed (bad amount, unknown destination type, ...) |
| 401 | Missing/invalid signature, unknown key, expired timestamp, or a replayed nonce |
| 403 | Authenticated, but you don't hold the required access level on the resource |
| 404 | The resource doesn't exist, or you have no relationship to it |
| 429 | Rate limited — see below |
| 503 | The service is temporarily unable to reach its database |
| 500 | An unexpected server-side error |
Rate limits
| Scope | Limit |
|---|---|
| Every request, per API key | 120 requests / minute |
| Withdrawal initiation, per wallet | 10 / 15 minutes |
| Withdrawal OTP confirmation, per withdrawal | 5 attempts / 5 minutes (matches the OTP's own expiry) |
| Escrow creation, per wallet | 20 / 15 minutes |
| Escrow release / dispute / resolve, per escrow | 30 / 15 minutes |
| Crypto payment request creation, per wallet | 30 / 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.