Snap Pay Developers Postman collection
Merchant API v1

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.

Everything here is a plain HTTPS + JSON API. Import the Postman collection to try each call against the sandbox in a few minutes.

Environments

EnvironmentBase URLKey starts withMoney
Sandboxhttps://uat.snapcashpay.com/api/v1sk_test_Test only. No real money moves.
Livehttps://snapcashpay.com/api/v1sk_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.

Authorization: Bearer sk_live_...
Keep the key on your server. Never put it in a mobile app, a browser or a public repository. If it leaks, generate a new one: the old key stops working immediately.

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": 1500 or "1500.50".
  • Responses give amounts as decimal strings ("1500.00"). Fields ending in _minor are 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, header Idempotent-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

statusMeaning
processingAccepted and sent to the network; the money has left your balance. Most payouts finish within a minute.
succeededThe network confirmed the recipient was paid.
failedThe recipient was not paid. If the money had left your balance, it is returned to it.
reversedA 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

WhatLimit
Create payout30 requests per minute
Number checks30 requests per minute
Rates, lists60 requests per minute
Get one payout120 requests per minute
API payouts per dayPer account and currency (default KES 300,000 / UGX 10,000,000). Ask us to change it.
Per payoutThe 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.

Payouts and number checks are switched on per account. Until we enable them for you, those endpoints return 403 payouts_not_enabled. Balance, rates and transactions work from day one.

Balance

GET/balance

Your business balance in your main currency.

{
  "currency": "KES",
  "balance_minor": 1250000,
  "balance": "12500.00"
}

Rates

GET/rates?from=KES&to=UGX&amount=1000

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.

QueryDescription
fromrequiredCurrency you pay from: KES or UGX.
torequiredCurrency the recipient receives.
amountoptionalAdds 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

POST/accounts/lookup

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.

FieldDescription
phonerequiredNumber in international format, e.g. 256772123456.
countryrequiredKE or UG.
networkoptionalmpesa, 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

POST/payouts

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.

HeaderDescription
Idempotency-KeyrequiredUnique per payout. See Idempotency.
FieldDescription
amountrequiredAmount taken from your balance, in currency.
currencyoptionalBalance to pay from: KES or UGX. Defaults to your main currency.
recipient_phonerequiredInternational format, e.g. 254712345678.
recipient_namerequiredRecipient's name, up to 128 characters.
recipient_countryrequiredKE or UG.
networkoptionalmpesa, mtn or airtel.
narrationoptionalYour note, up to 140 characters.
client_referenceoptionalYour 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

GET/payouts/{reference}

Returns the same payout object as above with its current status. 404 if the reference is not one of your API payouts.

List payouts

GET/payouts?status=succeeded&client_reference=INV-10293&per_page=25&page=1

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

GET/transactions?per_page=25&page=1

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

GET/payments/{reference}

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.

eventWhen
payout.succeededThe recipient was paid.
payout.failedThe recipient was not paid.
payout.reversedA 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 "", 200
A webhook is a notification, not the record. If you miss one, GET /payouts/{reference} always has the current status.

Store 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.

EndpointUse
POST /merchant/fx/lock-rateLock a KES/UGX rate for 15 minutes for a cross-border cart. Body: from_currency, to_currency, amount_minor.
POST /merchant/collectSend 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/transactionsOrder payments; filters order_id, state, per_page.
GET /merchant/delivery-codeThe delivery code to include in your own order email (order_id, order_key).
POST /merchant/verify-delivery-codeConfirm delivery with the customer's code (order_id, order_key, code). Releases the held money to your store.
GET /merchant/walletHeld 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

Countryrecipient_countryCurrencynetworkName check
KenyaKEKESmpesa, airtelFormat only
UgandaUGUGXmtn, airtelRegistered 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."] }
}
HTTPerrorWhat to do
400idempotency_key_requiredAdd an Idempotency-Key header.
401unauthenticatedMissing, wrong or replaced API key.
403payouts_not_enabledAsk us to enable API payouts for your account.
404not_foundThe reference is not yours, or does not exist.
409idempotency_conflictThe key was used for a different payout. Use a new key for a new payout.
409request_in_progressThe same key is still being processed. Retry in a few seconds.
422validation_failedFix the fields listed in details.
422payout_refusedShow the message (e.g. not enough balance). Nothing was sent.
422daily_limit_exceededOver your daily API payout limit.
422unsupported_currency, rate_unavailable, lookup_unavailableSee message; try again later for the last two.
429(rate limit)Wait for Retry-After seconds.
502payout_errorThe 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:

POST/sandbox/payouts/{reference}/simulate
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

DateChange
29 Oct 2026Payouts, rates, number checks, payout webhooks, sandbox simulate, Postman collection and OpenAPI spec. Keys created before this date must be regenerated.