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

Base URLhttps://api.smplypay.com
Web apphttps://smplypay.com — the dashboard itself; unrelated to the API calls below, which always go to the base URL above instead

Every path on this page (e.g. /api/v1/wallets/:wallet_id/balance) is relative to that base URL — so a full request is, for example, POST https://api.smplypay.com/api/v1/wallets/:wallet_id/withdrawals.

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

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. Listen for the deposit.settled webhook, or poll the push you started with the endpoint below (useful when your webhook endpoint may not be reachable).

GET/api/v1/wallets/:wallet_id/deposits/stk/:deposit_id

Requires View-level access to the wallet. :deposit_id is the id returned when the push was created. Returns the same shape as above, with the current status and M-Pesa's result_description once it has answered. A deposit id that belongs to a different wallet than the one in the path returns 404, even if you have access to that other wallet.

GET/api/v1/wallets/:wallet_id/deposits/paybill

Requires View-level access. The Paybill business number plus this wallet's own account number, for showing your users how to top up from the M-Pesa menu without an STK push. The account number is created on the first call and stays the same afterwards.

Response — 200 OK
{
  "business_short_code": "174379",
  "account_number": "SP7K2M9Q"
}

Paybill payments credit the wallet as a paybill_deposit ledger entry and fire deposit.settled.

Wallet balance

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

Requires View-level access. Returns every currency this platform supports — sixteen rows: KES, the native coin of each of the five crypto networks, and USDT and USDC on each of those five networks — regardless of whether the wallet actually holds a non-zero balance in each one; a freshly-created wallet returns sixteen rows all reading "0", not an empty array or a filtered list. Rows are sorted by currency string (so KES first). Crypto currencies are identified as "{network}:{symbol}" (e.g. "ethereum:ETH", "polygon:USDT") — not the bare symbol alone, since the same symbol exists on more than one network.

Response — 200 OK (a wallet with a KES deposit and some Polygon USDT)
[
  { "currency": "KES", "amount": "500.000000000000000000" },
  { "currency": "celo:CELO", "amount": "0" },
  { "currency": "celo:USDC", "amount": "0" },
  { "currency": "celo:USDT", "amount": "0" },
  { "currency": "ethereum:ETH", "amount": "0" },
  { "currency": "ethereum:USDC", "amount": "0" },
  { "currency": "ethereum:USDT", "amount": "0" },
  { "currency": "optimism:OP", "amount": "0" },
  { "currency": "optimism:USDC", "amount": "0" },
  { "currency": "optimism:USDT", "amount": "0" },
  { "currency": "polygon:POL", "amount": "0" },
  { "currency": "polygon:USDC", "amount": "0" },
  { "currency": "polygon:USDT", "amount": "12.500000000000000000" },
  { "currency": "solana:SOL", "amount": "0" },
  { "currency": "solana:USDC", "amount": "0" },
  { "currency": "solana:USDT", "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 — KES to M-Pesa paybill (B2B)
{
  "amount": "500",
  "destination_type": "paybill",
  "business_number": "888880",
  "account_number": "ABC123"
}
Request body — KES to M-Pesa till / buy goods (B2B)
{
  "amount": "500",
  "destination_type": "buy_goods",
  "till_number": "999999"
}
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",
    "reference_id": "0b6f19c2-5d4e-4a7b-9c1e-2f8d7a6b5c40",
    "created_at": "2026-08-31T12:00:00Z"
  }
]

amount is signed: credits are positive, debits negative. reference_id is the record the entry belongs to — the withdrawal id for withdrawal entries, the escrow id for escrow entries, the transfer id for transfer entries — or null when there is none. For M-Pesa deposits it is an internal receipt-record id (not the STK deposit id), useful only as a stable identifier. Use it to fetch details the entry doesn't carry — for example, a withdrawal_debit is the gross amount (payout plus fee); fetch GET /api/v1/withdrawals/:reference_id for its fee_amount and net_amount. Entry types include stk_deposit, paybill_deposit, crypto_deposit, withdrawal_debit, withdrawal_reversal, the escrow entries (escrow_funding_debit, escrow_release_credit, escrow_commission_credit, escrow_refund_credit, …) and transfer_debit/transfer_credit.

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. reference is an optional string of up to 255 characters for your own correlation (e.g. your order id); it is returned on every escrow view and webhook.

Request body
{
  "receiver_wallet_id": "b8f3...",
  "manager_wallet_id": null,
  "currency": "KES",
  "amount": "10000",
  "commission_rate_percentage": "2.5",
  "reference": "order-4821"
}
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": [],
  "reference": "order-4821",
  "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
}
POST/api/v1/wallets/:wallet_id/escrows/batch

Funds several escrows from one depositor wallet as a single all-or-nothing unit — e.g. one checkout paying several sellers. Requires Manage-level access. Each entry takes the same fields and validation as the single-escrow request above; between 1 and 50 entries. If the wallet can't cover the total in any currency, nothing is created and the response is 422 ("insufficient KES balance to fund this batch of escrows").

Request body
{
  "escrows": [
    { "receiver_wallet_id": "b8f3...", "manager_wallet_id": "9a1c...", "currency": "KES",
      "amount": "6000", "commission_rate_percentage": "5", "reference": "order-4821" },
    { "receiver_wallet_id": "d2e7...", "manager_wallet_id": "9a1c...", "currency": "KES",
      "amount": "4000", "commission_rate_percentage": "5", "reference": "order-4822" }
  ]
}

Response — 201 Created: an array of EscrowViews, in request order.

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/partial-refund

Returns part of a still-funded escrow to the depositor and reduces the escrow by the same amount — e.g. a seller supplies less than was paid for. Only the receiver (giving up part of their own payout) or the manager may do it, with Manage-level access to that wallet; the depositor gets 403. Refused with 422 once either party has confirmed, or the escrow is disputed or settled, so an agreed amount never changes under the parties. amount must be less than the escrowed amount — a full refund is a dispute resolution. reason is optional (up to 500 characters). Commission is later calculated on the reduced amount.

Request body
{ "amount": "1500", "reason": "One item out of stock" }

Response — 200 OK: the updated EscrowView (amount is now the remaining amount). Fires escrow.partially_refunded; the depositor's wallet shows an escrow_refund_credit entry.

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" }

Wallet transfers

POST/api/v1/wallets/:wallet_id/transfers

Moves funds instantly from :wallet_id (the source — requires Manage-level access) to another wallet on the platform. Both ledger entries are posted in one database transaction. reference is required (up to 255 characters) and is the idempotency key: repeating a request with a reference already used from this source wallet returns the original transfer with 200 OK instead of moving money again, so retries are safe. description is optional (up to 500 characters).

Request body
{
  "destination_wallet_id": "b8f3...",
  "currency": "KES",
  "amount": "2500",
  "reference": "iou-payment-7781",
  "description": "IOU INV-7781 repayment"
}
Response — 201 Created (200 OK for a repeated reference)
{
  "id": "5b0e...",
  "source_wallet_id": "43d71324-0af8-4efa-8211-c43cb9c47423",
  "destination_wallet_id": "b8f3...",
  "currency": "KES",
  "amount": "2500",
  "reference": "iou-payment-7781",
  "description": "IOU INV-7781 repayment",
  "debit_ledger_entry_id": "7c1d...",
  "credit_ledger_entry_id": "8e2f...",
  "created_at": "2026-09-23T10:00:00Z"
}

422 for a zero/negative amount, the same wallet on both sides, an unknown destination wallet or currency, or insufficient balance. The source shows a transfer_debit and the destination a transfer_credit in transaction history; transfer.completed fires (not deposit.settled).

GET/api/v1/transfers/:transfer_id

The same TransferView shape. Requires View-level access to either the source or the destination wallet.

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.

Which deposits are detected on a live deployment: the live chain readers watch only the USDT and USDC token contracts (EVM) and mints (Solana) configured on the server ({NETWORK}_USDT_CONTRACT, SOLANA_USDC_MINT, …). Native coins (ETH, POL, CELO, OP, SOL) sent to a wallet address are not detected or credited, so request payments in a configured stablecoin (token_symbol "USDT" or "USDC"). Sandbox deployments simulate deposits instead, unless CRYPTO_LIVE_CHAINS=true points them at real (test) networks.
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.

Partner Integrations

Everything above authenticates as a user who already has an account. The routes below are different: they let a vetted, admin-approved partner create accounts and wallets on SMPLY PAY for its own end users, using a separate credential tier (a partner key, not a regular API key). Contact SMPLY PAY to be set up as a partner — there is no self-service signup for this tier.

How you receive your partner key

When SMPLY PAY sets your organization up as a partner, nobody at SMPLY PAY ever sees your key's secret — not even the admin who creates your partner record. Instead, a one-time claim link is emailed straight to the technical contact address you provide. Opening that link is the only place the secret is ever generated and shown — once, in your own browser, never recoverable afterward. Losing it means asking SMPLY PAY to send a fresh invite, the same as losing a regular API key's secret. The same flow issues any additional or rotated partner key later.

Creating accounts & wallets

Signed the same way as every other request on this page (HMAC-SHA256, see Authentication), just with a partner key's own key_id/secret instead of a regular API key's — the canonical string format and headers are identical. A partner key can only create accounts (plus the two narrow, same-partner-only actions below) — every other action on an account (balance checks, deposits, withdrawals) goes through that account's own ordinary API key, returned below, using the exact same /api/v1/... surface documented above. Never reuse the partner key itself for those calls. A partner can only ever act on accounts it itself created — every route below rejects a request from a different partner's key, even one that's otherwise validly signed.

Source-IP allowlist. SMPLY PAY can restrict a partner's keys to the IP addresses or CIDR ranges your integration calls from. Once an allowlist is set for your partner account, any partner-key request from another address is refused with 403 ("requests for this partner are not allowed from this IP address"), even if it's correctly signed. Send us your servers' outbound IPs before going live, and tell us before they change. With no allowlist set, requests are accepted from any IP.

POST/api/v1/partner/accounts

Creates a managed account — no password anyone (including SMPLY PAY) ever knows — plus one default wallet and that account's own API key. The account is fully usable via the returned key immediately. Its owner also gets an email with a link to set a real password and log in directly, whenever they want to; nothing about using the account depends on that ever happening.

email is always required. For everything else, either give first_name + last_name (an individual account) or business_name (a business account, e.g. "SMPLYPAY INNOVATION LIMITED") — at least one of the two is required, and nothing stops you sending both (a named contact person at a business). phone_number is always optional. webhook_url is optional too — set it to register a webhook endpoint for this account in the same call, so you start receiving deposit/withdrawal/escrow events immediately instead of a second signed POST /api/v1/webhooks call afterward. Purely a convenience: it's the exact same registration (and the same SSRF guard) that endpoint already runs — nothing here is a separate security mechanism.

Request body — individual account, with a webhook
{
  "email": "enduser@example.com",
  "first_name": "Jane",
  "last_name": "Doe",
  "phone_number": "0712345678",
  "webhook_url": "https://your-service.example.com/webhooks/smply-pay"
}
Request body — business account
{
  "email": "accounts@smplypay-innovation.example.com",
  "business_name": "SMPLYPAY INNOVATION LIMITED"
}
Response — 201 Created
{
  "user_id": "b6e2...",
  "wallet_id": "43d7...",
  "display_name": "SMPLYPAY INNOVATION LIMITED",
  "api_key_id": "3f2c...",
  "api_key_secret": "shown-exactly-once...",
  "webhook_id": "9d2a...",
  "webhook_secret": "shown-exactly-once...",
  "created_at": "2026-09-16T12:00:00Z"
}

Save these against your own record for this user. Use api_key_id as X-Api-Key-Id on every subsequent request for this account, together with api_key_secret (shown exactly once here) to compute X-Signature — the same as a regular API key's own credential pair. display_name is business_name if you sent one, otherwise the joined first/last name, or null if neither was given. webhook_id/webhook_secret are null whenever webhook_url was omitted — and, deliberately, also null rather than a failing response if you gave one that got rejected (a private/ unreachable address) — the account itself is always fully created either way; register a webhook as a follow-up call if this happens. webhook_secret signs deliveries the identical way any other webhook endpoint's does — see Verifying deliveries.

POST/api/v1/partner/accounts/:user_id/wallets

An additional wallet for an account this same partner previously created — the same underlying action POST /wallets performs for a session-authenticated user (a real use case: a partner's end user wants a second wallet to keep one currency/purpose separate from the first). No request body — everything this endpoint needs (which account, which partner) comes from the URL and the signed request itself.

Response — 201 Created
{
  "wallet_id": "9d2a...",
  "owner_user_id": "b6e2...",
  "created_at": "2026-09-16T12:10:00Z"
}

wallet_id — the new wallet's id; use this as :wallet_id in every wallet-scoped call from here on (GET .../balance, POST .../deposits/stk, POST .../withdrawals, etc. — see the sections above). owner_user_id — always identical to the :user_id you called this with; included so the response is self-contained without needing to cross-reference the request URL. A brand-new wallet starts with a zero balance in every currency this platform supports (see Deposits for how to actually fund it) — there's nothing further to configure before it's usable.

POST/api/v1/partner/accounts/:user_id/api-keys

Replaces every currently active API key on an account this same partner created, and returns a fresh one — the recovery path if api_key_secret from account creation is ever lost before you persist it (there is no “view secret again” endpoint, and an unclaimed account has no password anyone could log in with to create a new one from the dashboard instead). No request body.

Only works while the account is still unclaimed. Once its owner sets a password (via the claim email from account creation), key management moves entirely to their own session login — this endpoint starts returning 403, on purpose: silently rotating a real person's credentials out from under them once they've taken over the account would be a surprising, unwanted outage, not a recovery.

Response — 201 Created
{
  "api_key_id": "7c1e...",
  "api_key_secret": "shown-exactly-once...",
  "created_at": "2026-09-16T12:05:00Z"
}

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)
escrow.partially_refundedPart of a still-funded escrow is returned to the depositor
transfer.completedA wallet-to-wallet transfer is posted (sent to both wallets' Manage-level members)
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",
  "ledger_entry_id": "f1a2...",
  "reference_id": "0b6f19c2-5d4e-4a7b-9c1e-2f8d7a6b5c40"
}
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.partially_refunded
{
  "escrow_id": "c4d5...",
  "status": "released",
  "reference": "order-4821",
  "currency": "KES",
  "amount": "10000",
  "receiver_amount": "9750",
  "commission_amount": "250",
  "commission_rate_percentage": "2.5",
  "depositor_wallet_id": "43d71324-0af8-4efa-8211-c43cb9c47423",
  "receiver_wallet_id": "b8f3...",
  "manager_wallet_id": null,
  "result_description": null
}

Amounts and parties are included so you can reconcile without a follow-up GET. For a refunded escrow, receiver_amount and commission_amount are "0"; after a partial refund, amount is the remaining escrowed amount. deposit.settled carries ledger_entry_id (unique per deposit — a safe dedupe key) and reference_id (an internal source-record id, when there is one — for M-Pesa this is the processed-receipt record, not the STK deposit id).

transfer.completed
{
  "transfer_id": "5b0e...",
  "source_wallet_id": "43d71324-0af8-4efa-8211-c43cb9c47423",
  "destination_wallet_id": "b8f3...",
  "currency": "KES",
  "amount": "2500",
  "reference": "iou-payment-7781",
  "description": "IOU INV-7781 repayment"
}
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.*/transfer.completed above, which fan out to every Manage-level member of the wallet(s) 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, or (partner keys) the request came from an IP outside your partner allowlist
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.