Snap Pay API
Pay out to M-Pesa, MTN and Airtel numbers from your Snap Pay business balance, quote cross-border rates, check who owns a number before you pay it, and follow every payment, from your own server.
Payouts
Send money from your KES or UGX balance to a mobile money number in Kenya or Uganda, including across the border.
Rates
Get the rate a cross-border payout will use before you send it.
Number checks
See the registered name on an MTN or Airtel Uganda number before paying it.
Webhooks
Signed notifications when a payout succeeds, fails or is reversed.
Environments
| Environment | Base URL | Key starts with | Money |
|---|---|---|---|
| Sandbox | https://uat.snapcashpay.com/api/v1 | sk_test_ | Test only. No real money moves. |
| Live | https://snapcashpay.com/api/v1 | sk_live_ | Real money. |
The two environments are completely separate: a sandbox key only works on the sandbox URL and a live key only on the live URL. Accounts, balances and transactions are not shared between them.
Quickstart
1. Get your keys
Sign in to your Snap Pay business account, open Business → Settings → WooCommerce & API and choose Generate API key. Copy it straight away: it is shown once. On the same screen, set your Webhook URL and generate a webhook signing secret. For a sandbox account, contact us.
2. Make your first call
curl https://uat.snapcashpay.com/api/v1/balance \ -H "Authorization: Bearer sk_test_your_key" \ -H "Accept: application/json"
const res = await fetch("https://uat.snapcashpay.com/api/v1/balance", {
headers: {
Authorization: "Bearer " + process.env.SNAPPAY_KEY,
Accept: "application/json",
},
});
console.log(await res.json());$ch = curl_init('https://uat.snapcashpay.com/api/v1/balance');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SNAPPAY_KEY'),
'Accept: application/json',
],
]);
echo curl_exec($ch);import os, requests
r = requests.get(
"https://uat.snapcashpay.com/api/v1/balance",
headers={"Authorization": "Bearer " + os.environ["SNAPPAY_KEY"],
"Accept": "application/json"},
)
print(r.json()){
"currency": "KES",
"balance_minor": 1250000,
"balance": "12500.00"
}3. Send a payout
Check the number with accounts/lookup, then create the payout with an Idempotency-Key. You get a 202 straight away and a webhook when the network confirms it.
Postman & OpenAPI
Every endpoint on this page is in the Postman collection, with example bodies and saved example responses. Import the collection and one environment, paste your key into the apiKey variable, and send.
In Postman: Import → drop in the files → pick Snap Pay Sandbox in the environment menu (top right) → set apiKey. Payout requests generate a fresh Idempotency-Key each time you press Send.
Authentication
Send your key as a Bearer token on every request, over HTTPS. Requests without a valid key get 401.
Also send Accept: application/json (and Content-Type: application/json on POST) so errors come back as JSON.
Amounts & currencies
- Request amounts are decimal numbers in major units:
"amount": 1500or"1500.50". - Responses give amounts as decimal strings (
"1500.00"). Fields ending in_minorare integers in cents (150000= 1,500.00). - Currencies are ISO 4217 codes:
KES,UGX. Countries are ISO 3166 two-letter codes:KE,UG. - Times are ISO 8601 with offset, in East Africa Time (UTC+3).
Idempotency
POST /payouts requires an Idempotency-Key header: 8 to 64 letters, digits, - or _, unique per payout. A UUID works well.
- Send the same key again with the same body and you get the original payout back (
200, headerIdempotent-Replayed: true). Nothing is paid twice. - The same key with a different amount, number or recipient gets
409 idempotency_conflict. - If your request timed out, retry with the same key. Never generate a new key to retry a payout you are unsure about.
Payout status
| status | Meaning |
|---|---|
processing | Accepted and sent to the network; the money has left your balance. Most payouts finish within a minute. |
succeeded | The network confirmed the recipient was paid. |
failed | The recipient was not paid. If the money had left your balance, it is returned to it. |
reversed | A completed payout was reversed after review. The amount is back in your balance. |
Treat only succeeded as paid. Wait for the webhook, or poll GET /payouts/{reference} no more than once every 10 seconds.
Limits
| What | Limit |
|---|---|
| Create payout | 30 requests per minute |
| Number checks | 30 requests per minute |
| Rates, lists | 60 requests per minute |
| Get one payout | 120 requests per minute |
| API payouts per day | Per account and currency (default KES 300,000 / UGX 10,000,000). Ask us to change it. |
| Per payout | The mobile money network's own minimum and maximum for the recipient's number. |
Over a rate limit you get 429 with a Retry-After header.
403 payouts_not_enabled. Balance, rates and transactions work from day one.Balance
Your business balance in your main currency.
{
"currency": "KES",
"balance_minor": 1250000,
"balance": "12500.00"
}Rates
The rate a payout from from to a recipient paid in to would use right now. It is a quote, not a lock: the payout uses the rate at the moment it is made.
| Query | Description | |
|---|---|---|
from | required | Currency you pay from: KES or UGX. |
to | required | Currency the recipient receives. |
amount | optional | Adds converted_amount for this amount of from. |
{
"from": "KES",
"to": "UGX",
"rate": 27.85,
"indicative": true,
"note": "The rate is applied when the payout is made and is not locked by this quote.",
"quoted_at": "2026-10-29T10:15:02+03:00",
"amount": "1000.00",
"converted_amount": "27850.00"
}Check a number
Checks a mobile money number on its network before you pay it. MTN and Airtel Uganda return the registered name; Kenyan numbers are checked for format only, so name is null. Show the name to whoever is paying, so they can confirm it before you send.
| Field | Description | |
|---|---|---|
phone | required | Number in international format, e.g. 256772123456. |
country | required | KE or UG. |
network | optional | mpesa, mtn or airtel. Leave out to use the country's default network. |
curl https://uat.snapcashpay.com/api/v1/accounts/lookup \
-H "Authorization: Bearer sk_test_your_key" \
-H "Content-Type: application/json" -H "Accept: application/json" \
-d '{"phone":"256772123456","country":"UG","network":"mtn"}'{
"valid": true,
"name": "JANE NAKATO",
"name_available": true,
"phone": "256772123456",
"country": "UG"
}Create a payout
Pays a mobile money number from your balance. When the recipient's country uses another currency (for example a KES balance paying a Ugandan number), the amount is converted at the current rate.
| Header | Description | |
|---|---|---|
Idempotency-Key | required | Unique per payout. See Idempotency. |
| Field | Description | |
|---|---|---|
amount | required | Amount taken from your balance, in currency. |
currency | optional | Balance to pay from: KES or UGX. Defaults to your main currency. |
recipient_phone | required | International format, e.g. 254712345678. |
recipient_name | required | Recipient's name, up to 128 characters. |
recipient_country | required | KE or UG. |
network | optional | mpesa, mtn or airtel. |
narration | optional | Your note, up to 140 characters. |
client_reference | optional | Your own ID for this payout (up to 64). Returned on every response and webhook, and filterable. |
curl https://uat.snapcashpay.com/api/v1/payouts \
-H "Authorization: Bearer sk_test_your_key" \
-H "Content-Type: application/json" -H "Accept: application/json" \
-H "Idempotency-Key: 6f1c2b9e-1d2a-4f2e-9a7b-3c5d8e0f1a2b" \
-d '{
"amount": 1500,
"currency": "KES",
"recipient_phone": "254712345678",
"recipient_name": "John Kamau",
"recipient_country": "KE",
"network": "mpesa",
"narration": "October commission",
"client_reference": "INV-10293"
}'import { randomUUID } from "node:crypto";
const res = await fetch("https://uat.snapcashpay.com/api/v1/payouts", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.SNAPPAY_KEY,
"Content-Type": "application/json",
Accept: "application/json",
"Idempotency-Key": randomUUID(), // store it; reuse it if you retry
},
body: JSON.stringify({
amount: 1500,
currency: "KES",
recipient_phone: "254712345678",
recipient_name: "John Kamau",
recipient_country: "KE",
network: "mpesa",
client_reference: "INV-10293",
}),
});
const payout = await res.json();$key = bin2hex(random_bytes(16)); // store it; reuse it if you retry
$ch = curl_init('https://uat.snapcashpay.com/api/v1/payouts');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SNAPPAY_KEY'),
'Content-Type: application/json',
'Accept: application/json',
'Idempotency-Key: ' . $key,
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => 1500,
'currency' => 'KES',
'recipient_phone' => '254712345678',
'recipient_name' => 'John Kamau',
'recipient_country' => 'KE',
'network' => 'mpesa',
'client_reference' => 'INV-10293',
]),
]);
$payout = json_decode(curl_exec($ch), true);{
"object": "payout",
"reference": "SNP-7K2M9QXA",
"client_reference": "INV-10293",
"status": "processing",
"amount": "1500.00",
"currency": "KES",
"destination_amount": "1500.00",
"destination_currency": "KES",
"fx_rate": 1,
"fee": "0.00",
"recipient": {
"name": "John Kamau",
"phone": "254712345678",
"country": "KE",
"network": "mpesa"
},
"narration": "October commission",
"created_at": "2026-10-29T10:16:40+03:00",
"updated_at": "2026-10-29T10:16:41+03:00"
}A refused payout (for example not enough balance, or over the network's limit) returns 422 with "error": "payout_refused" and a message you can show; nothing is sent. If the network turns the number down straight away, you get the payout back with "status": "failed".
Get a payout
Returns the same payout object as above with its current status. 404 if the reference is not one of your API payouts.
List payouts
Your API payouts, newest first. All filters are optional: status (processing, succeeded, failed, reversed), client_reference, per_page (1 to 100), page.
{
"data": [ { "object": "payout", "reference": "SNP-7K2M9QXA", "status": "succeeded", "...": "..." } ],
"meta": { "current_page": 1, "last_page": 4, "total": 87 }
}Transactions
Everything that moved in or out of your business account (customer payments, payouts made in the portal or through the API), newest first. status here is the internal status: processing, settled, failed, reversed, cancelled.
{
"data": [
{
"reference": "SNP-4QH2W8LD",
"status": "settled",
"amount": "2500.00",
"currency": "KES",
"fee_minor": 3750,
"buyer_phone": "254722000111",
"payment_link": "SNAP-PL-8DK2LQ0Z",
"created_at": "2026-10-28T16:02:11+03:00"
}
],
"meta": { "current_page": 1, "last_page": 12, "total": 291 }
}Payment status
One transaction from the list above, by its reference. 404 with {"error": "Payment not found"} if it is not yours.
Webhooks
When an API payout reaches a final status we POST it to the Webhook URL in your settings (HTTPS only). Answer with any 2xx within 10 seconds. If you don't, we retry after 1, 5, 15, 60, 180 and 360 minutes.
| event | When |
|---|---|
payout.succeeded | The recipient was paid. |
payout.failed | The recipient was not paid. |
payout.reversed | A completed payout was reversed. |
POST /your/webhook/url
Content-Type: application/json
X-Snap-Signature: sha256=5d3c2f...e91a
X-Snap-Timestamp: 1761722201
X-Snap-Event-Id: evt_0b6f4b7e-2a8e-4c57-9d0e-1f3a6c9b2d41
{
"event": "payout.succeeded",
"event_id": "evt_0b6f4b7e-2a8e-4c57-9d0e-1f3a6c9b2d41",
"created_at": "2026-10-29T10:16:58+03:00",
"data": { "object": "payout", "reference": "SNP-7K2M9QXA", "status": "succeeded", "...": "..." }
}Verify the signature
X-Snap-Signature is sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with your webhook signing secret. Compute it over the bytes exactly as received (before parsing the JSON), compare in constant time, and reject anything that doesn't match. Deliveries can repeat: use event_id to ignore duplicates.
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post("/snappay/webhook", express.raw({ type: "application/json" }), (req, res) => {
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.SNAPPAY_WEBHOOK_SECRET)
.update(req.body) // raw Buffer, not parsed JSON
.digest("hex");
const given = req.get("X-Snap-Signature") || "";
const ok = given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body);
// if (alreadyHandled(event.event_id)) return res.sendStatus(200);
// update your order using event.data.reference / event.data.client_reference
res.sendStatus(200);
});$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, getenv('SNAPPAY_WEBHOOK_SECRET'));
$given = $_SERVER['HTTP_X_SNAP_SIGNATURE'] ?? '';
if (! hash_equals($expected, $given)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// skip if $event['event_id'] was already processed
// update your order using $event['data']['reference']
http_response_code(200);import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/snappay/webhook")
def snappay_webhook():
raw = request.get_data() # raw bytes
expected = "sha256=" + hmac.new(
os.environ["SNAPPAY_WEBHOOK_SECRET"].encode(), raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-Snap-Signature", "")):
abort(401)
event = request.get_json()
# skip duplicates by event["event_id"]; update your records
return "", 200Store checkout API
Online stores (the Snap Pay WooCommerce plugin, or your own cart) use a separate set of endpoints under /api/v1/merchant. A customer pays by mobile money prompt and the money is held until the customer confirms delivery with an 8-character code, then released to the store.
Store keys are issued per store and per environment (sk_test_ / sk_live_) by Snap Pay. Contact us to connect a store. Send X-Store-Url with your store's address on every call.
| Endpoint | Use |
|---|---|
POST /merchant/fx/lock-rate | Lock a KES/UGX rate for 15 minutes for a cross-border cart. Body: from_currency, to_currency, amount_minor. |
POST /merchant/collect | Send the payment prompt for an order. Body: order_id, order_key, amount_minor, currency, customer_email, customer_phone, customer_name, billing_country, provider_id (e.g. mpesa_ke, mtn_ug), callback_url; optional fx_lock_id, display_currency, display_amount_minor. Supports Idempotency-Key. Returns 202. |
GET /merchant/transactions/{reference} | One order payment, with its delivery-code state. |
GET /merchant/transactions | Order payments; filters order_id, state, per_page. |
GET /merchant/delivery-code | The delivery code to include in your own order email (order_id, order_key). |
POST /merchant/verify-delivery-code | Confirm delivery with the customer's code (order_id, order_key, code). Releases the held money to your store. |
GET /merchant/wallet | Held and available balances, reserve, and your fee schedule. |
Store webhooks (transaction.succeeded, transaction.failed, delivery.verified) use the same X-Snap-Signature scheme as above, signed with the store's own secret.
Countries & networks
| Country | recipient_country | Currency | network | Name check |
|---|---|---|---|---|
| Kenya | KE | KES | mpesa, airtel | Format only |
| Uganda | UG | UGX | mtn, airtel | Registered name |
Pay from a KES or a UGX balance to either country. Other countries are added on request.
Errors
Errors are JSON with a stable machine-readable error code and a human message. Validation errors add details, keyed by field.
{
"error": "validation_failed",
"message": "The request failed validation.",
"details": { "recipient_country": ["The recipient country field must be 2 characters."] }
}| HTTP | error | What to do |
|---|---|---|
| 400 | idempotency_key_required | Add an Idempotency-Key header. |
| 401 | unauthenticated | Missing, wrong or replaced API key. |
| 403 | payouts_not_enabled | Ask us to enable API payouts for your account. |
| 404 | not_found | The reference is not yours, or does not exist. |
| 409 | idempotency_conflict | The key was used for a different payout. Use a new key for a new payout. |
| 409 | request_in_progress | The same key is still being processed. Retry in a few seconds. |
| 422 | validation_failed | Fix the fields listed in details. |
| 422 | payout_refused | Show the message (e.g. not enough balance). Nothing was sent. |
| 422 | daily_limit_exceeded | Over your daily API payout limit. |
| 422 | unsupported_currency, rate_unavailable, lookup_unavailable | See message; try again later for the last two. |
| 429 | (rate limit) | Wait for Retry-After seconds. |
| 502 | payout_error | The payout could not be started and nothing was sent. Retry with the same Idempotency-Key. |
Sandbox testing
The sandbox uses test networks. Payouts there never reach a real phone. Number checks return a test name.
Sandbox payouts stay processing until you finish them, so you can test both outcomes and your webhook handler on demand:
curl https://uat.snapcashpay.com/api/v1/sandbox/payouts/SNP-7K2M9QXA/simulate \
-H "Authorization: Bearer sk_test_your_key" \
-H "Content-Type: application/json" -H "Accept: application/json" \
-d '{"outcome":"succeeded"}'outcome is succeeded or failed. The response is the updated payout plus webhook_delivery (sent, failed or no_endpoint), and the webhook is sent immediately. This endpoint does not exist on live.
Changelog
| Date | Change |
|---|---|
| 29 Oct 2026 | Payouts, rates, number checks, payout webhooks, sandbox simulate, Postman collection and OpenAPI spec. Keys created before this date must be regenerated. |
Developers