Create a free account to see your test API key pre-filled in every example.

These docs are machine-readable: /docs/llms.txt · /docs.md · API changes are announced on the changelog. Response shapes are additive-only, webhook events are only ever added.

Choose Your Integration

Samsoftpay supports three integration shapes. Pick the row that matches what you're building — each links to the endpoints you need. They can be combined (a POS that both takes payments and pays out uses two of them).

ModeYou're buildingCustomer pays on a Samsoftpay screen?Who acts on success?Start here
A · Hosted checkout A web/app checkout, a payment link, a QR at the till — your device confirms the payment and then does its own thing (print receipt, unlock, dispense over its own hardware). Yes — we host the pay page + QR; you show it. You do. Poll GET /v1/charges/<id> or receive the charge.succeeded webhook, then act. We command nothing. Payment Links + Charges
B · Backend money-out Payroll, supplier payouts, merchant withdrawals — server-to-server disbursement. No customer, no checkout page. No — pure API. N/A — money goes out; you track payout.succeeded/payout.failed. Payouts (single & bulk)
C · Vending (machine present) A vending machine that shows a QR and dispenses a physical product — and you want us to command the machine to dispense once the money lands. Yes — the machine shows our QR; the customer scans and pays. We do. On payment success we command the machine to dispense and confirm the real outcome via the supplier callback. Vending Machines
Which one is "the QR mode"? Both A and C show a QR. The difference is who dispenses: in Mode A your own device watches for succeeded and acts itself (this is how a POS or a self-dispensing kiosk integrates — use /v1/payment-links); in Mode C Samsoftpay drives the machine for you (use /v1/vending/orders). Sending a Mode-C order to a machine that dispenses itself would double-fire — so pick by who acts, not by whether there's a QR. All three share the same keys, auth, balance and webhooks below.

How You Connect

The whole integration is two connections in opposite directions. Get these right and everything else follows.

① To transact  ·  YOUR server → US

Your backend calls our endpoint to create charges, payouts, check balance:

POST https://api.samsoftpay.com/v1/charges
Authorization: Bearer sk_live_…        ← your API key (proves YOU to US)

Same base URL for test and live — the key prefix (sk_test_ / sk_live_) picks the mode. This connection is always required: we hold the rails, so you call us to move money.

② To get notified  ·  US → YOUR server

We POST a signed event to your public URL when something changes:

POST https://www.yourdomain.com/webhooks/samsoftpay
X-Samsoftpay-Signature: …              ← verify with your whsec_ (proves US to YOU)

Set this URL on Account → Webhooks — it must be your own reachable HTTPS host, never api.samsoftpay.com. Optional: you can poll GET /v1/charges/<id> instead.

Two keys — don't mix them up

CredentialDirectionUsed forWhere you find it
sk_live_… / sk_test_…proves you → usAuthorization: Bearer on every API callYour Account page
whsec_…verifies us → youChecking X-Samsoftpay-Signature on incoming webhooksAccount → Webhooks (shown masked)
The API key authenticates your requests to us; the webhook secret lets you confirm our webhooks really came from us. They are different values, both per-account — you never need any Samsoftpay internal secret, only your own sk_ and whsec_.
Building with an AI coding agent? Integrate in minutes, not days. Point Codex, Claude, OpenCode, Kimi — or any coding agent — at our machine-readable docs: /openapi.json (a full OpenAPI 3.1 spec it can load as tools or generate a client from) and /docs.md (the whole guide as markdown), both discoverable via /docs/llms.txt. The agent has the complete contract, tests against the deterministic sandbox with self-serve test funds, and writes a working integration on its own. Once your account's KYC is verified (green), you're cleared to move live money — that is the only gate between a working sandbox integration and going live.

Quickstart

Simulate or real? Your API key prefix decides — there is no separate flag.
  • sk_test_… = sandbox: a charge completes on a short timer with no real PIN prompt and no real money, so you can test the whole scan → pay → dispense loop safely. It's deterministic — an ordinary test number always succeeds; specific magic numbers force insufficient_funds / user_cancelled / timeout.
  • sk_live_… = live: the customer gets a real Mobile Money PIN prompt on their phone and real money moves before we confirm success.
Every charge and webhook carries a mode field (test / live) so you always know which you're handling. In production, only release the goods / dispense on mode: "live" — a sandbox "instant success" is a simulation, not a payment.

To integrate with Samsoftpay you need exactly two things:

  1. Base URL: https://api.samsoftpay.com (same for test and live).
  2. Your API key: sk_test_… from a free account (instant, no approval), sent as Authorization: Bearer <key>.

No IPN registration, no IP whitelisting, no token exchange. This first charge works in under a minute:

curl -X POST https://api.samsoftpay.com/v1/charges \
  -H "Authorization: Bearer sk_test_YOUR_TEST_KEY" \
  -H "Idempotency-Key: my-first-charge-001" \
  -H "X-Timestamp: $(date +%s)" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 5000,
    "currency": "UGX",
    "channel": "mtn_momo",
    "customer": {"phone": "256700000000"},
    "reference": "order-001"
  }'

Response:

{
  "id": "txn_9f2c41d8a3b7e615",
  "mode": "test",
  "status": "authorized",
  "amount": 5000,
  "fee": 200,
  "currency": "UGX",
  "channel": "mtn_momo",
  "reference": "order-001"
}

Poll GET /v1/charges/<id> until status is succeeded. The test number 256700000000 always succeeds (see Testing for numbers that fail on demand). Prefer push? Add a webhook URL on your Account page and we deliver signed events instead. Webhooks are optional, and polling is fully supported.

Going live later is a one-line change: swap in your sk_live_ key. Nothing else changes.

Authentication

Samsoftpay uses Bearer token authentication. Pass your Secret Key in the Authorization header on every request.

Kiosk-safe keys: for code that ships on a public device (a vending machine, a kiosk app), use a collections-only key (sk_test_col_… / sk_live_col_…, generated on your Account page). It can create charges, orders and payment links but is refused with 403 on payouts and refunds, so a key pulled off a device can never move money out. Never embed your full secret key on a device.

Python JavaScript cURL
import requests

headers = {
    "Authorization": "Bearer sk_test_YOUR_TEST_KEY",
    "Content-Type": "application/json",
}
const headers = {
  "Authorization": "Bearer sk_test_YOUR_TEST_KEY",
  "Content-Type": "application/json",
};
curl -H "Authorization: Bearer sk_test_YOUR_TEST_KEY" \
     https://samsoftpay.com/v1/charges
Never expose your Secret Key in client-side JavaScript, mobile apps, or public repos. Use environment variables.

Required Headers

All POST requests require these additional headers:

HeaderDescription
Idempotency-Key required A unique UUID per request. Send the same key to safely retry without double-charging. Generate with uuid.uuid4() or crypto.randomUUID().
X-Timestamp required Current Unix timestamp in seconds (int(time.time())). Requests older than 5 minutes are rejected to prevent replay attacks.
import time, uuid

headers = {
    "Authorization": "Bearer sk_test_YOUR_TEST_KEY",
    "Idempotency-Key": str(uuid.uuid4()),
    "X-Timestamp": str(int(time.time())),
    "Content-Type": "application/json",
}

Idempotency semantics

Keys are reserved before execution: two concurrent requests with the same key can never both run; the loser gets 409 and should retry shortly. Replaying a key returns the original response, unchanged, plus the header Idempotent-Replayed: true so you can tell a replay from a fresh execution. Keys are retained for 30 days. On a network error, timeout or 5xx, retry with the same key. That is the whole point. Idempotency-Key is required on the money POSTs and optional-but-honored on payment links and vending orders.

Charges

Collect money from a customer via mobile money or card.

Create a Charge

POST /v1/charges
ParameterTypeDescription
amount requiredintegerAmount in UGX (minor units). Must be > 0.
currency optionalstringCurrently only "UGX". Default: "UGX".
channel requiredstring"mtn_momo" (live). "airtel_money" and "card" work with test keys only — live charges on them are refused until those rails launch.
customer.phone requiredstringCustomer's phone number. E.g. "256700123456".
reference optionalstringYour internal order/reference ID.
Python JavaScript cURL
import requests, time, uuid

resp = requests.post(
    "https://samsoftpay.com/v1/charges",
    headers={
        "Authorization": "Bearer sk_test_YOUR_TEST_KEY",
        "Idempotency-Key": str(uuid.uuid4()),
        "X-Timestamp": str(int(time.time())),
        "Content-Type": "application/json",
    },
    json={
        "amount": 10000,
        "currency": "UGX",
        "channel": "mtn_momo",
        "customer": {"phone": "256700123456"},
        "reference": "order-001",
    }
)
print(resp.json())
# {"id": "txn_abc123", "status": "authorized", "amount": 10000, "fee": 200, ...}
const resp = await fetch("https://samsoftpay.com/v1/charges", {
  method: "POST",
  headers: {
    "Authorization": "Bearer sk_test_YOUR_TEST_KEY",
    "Idempotency-Key": crypto.randomUUID(),
    "X-Timestamp": String(Math.floor(Date.now() / 1000)),
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    amount: 10000,
    currency: "UGX",
    channel: "mtn_momo",
    customer: { phone: "256700123456" },
    reference: "order-001",
  }),
});
const data = await resp.json();
console.log(data); // {id: "txn_abc123", status: "authorized", ...}
curl -X POST https://samsoftpay.com/v1/charges \
  -H "Authorization: Bearer sk_test_YOUR_TEST_KEY" \
  -H "Idempotency-Key: $(python3 -c 'import uuid; print(uuid.uuid4())')" \
  -H "X-Timestamp: $(date +%s)" \
  -H "Content-Type: application/json" \
  -d '{"amount":10000,"currency":"UGX","channel":"mtn_momo","customer":{"phone":"256700123456"}}'
A 1.5% fee (min UGX 200, cap UGX 5,000) is automatically calculated and returned in the fee field. The merchant receives amount - fee.

Get a Charge

GET /v1/charges/:id
resp = requests.get(
    "https://samsoftpay.com/v1/charges/txn_abc123",
    headers={"Authorization": "Bearer sk_test_YOUR_TEST_KEY"}
)
# status: "pending" | "authorized" | "succeeded" | "failed"

Testing

With an sk_test_ key, MTN Mobile Money and Airtel Money never touch a real network. A charge settles a few seconds after you request it, using the phone number as the signal for what should happen. An ordinary phone number always succeeds. Use one of these to test a specific outcome on purpose:

Phone numberResult
256700000000Always succeeds (same as any ordinary number, documented for clarity)
256700000001Fails with insufficient_funds
256700000002Fails with user_cancelled
256700000003Fails with timeout
Matched on the last 9 digits, so 0700000001, 256700000001 and 256 700 000 001 all trigger the same outcome. Any other number succeeds. A sandbox should never make an ordinary test transaction randomly fail.

Payout failure simulation

Payouts have their own deterministic scenarios, matched on the recipient number's last 9 digits. Rehearse your payout.failed handling before it can happen with real money. A failed payout refunds the amount and the fee to your available balance.

Recipient numberResult
256700000000Always succeeds
256700000001Fails with recipient_not_found
256700000002Fails with wallet_locked
256700000003Fails with timeout

Funding your sandbox wallet

A payout or refund needs available balance (amount + fee) or it is rejected before a pout_ id is created — a fresh sandbox starts empty. Two ways to add test money (it lives on a separate test ledger, is never withdrawable, and disappears at production go-live):

  • One click: Dashboard → Wallet → Add test funds. No KYC required to test — add as much as you need, instantly, then run payouts/refunds against it.
  • Or take a sandbox payment: create a charge with an sk_test_ key using an ordinary test number — it settles in seconds and credits your sandbox available balance (minus the fee).

Check it any time with GET /v1/balance: available must be ≥ Σ amounts + Σ fees before payroll/bulk runs.

Going live is a one-line change — swap sk_test_sk_live_. Same base URL, same endpoints, same webhooks. Sandbox test balances never cross into live.

Exact charge.failed payloads

Each failure number produces a charge.failed webhook whose data carries the core charge fields (id, amount, fee, currency, channel, status, reference, failure_reason, completed_at) plus mode. Build your error handling against these exact payloads:

256700000001:

{
  "id": "txn_9f2c41d8a3b7e615",
  "amount": 5000,
  "fee": 200,
  "currency": "UGX",
  "channel": "mtn_momo",
  "status": "failed",
  "merchant_reference": "order-001",
  "failure_reason": "insufficient_funds",
  "completed_at": "2026-08-20T10:30:00+00:00"
}

256700000002: same shape with "failure_reason": "user_cancelled".

256700000003: same shape with "failure_reason": "timeout".

A successful charge's charge.succeeded data is identical in shape with "status": "succeeded" and "failure_reason": null.

Balance

What Samsoftpay is currently holding for you, per currency. Use this to reconcile your own records against ours. If you run a platform with its own user wallets, this is the figure your internal balances must add up to.

The key you use decides which ledger you see. An sk_test_ key returns your sandbox balance, which is not real money. Never reconcile real liabilities against a sandbox figure.

Retrieve your balance

GET /v1/balance
resp = requests.get(
    "https://samsoftpay.com/v1/balance",
    headers={"Authorization": "Bearer sk_test_YOUR_TEST_KEY"}
)

# {
#   "mode": "live",
#   "balances": [
#     {"currency": "UGX", "available": 186250, "pending": 0, "cached": 186250, "total": 186250}
#   ],
#   "consistent": true,
#   "as_of": 1755900000
# }
FieldTypeDescription
availableintegerSettled funds you can pay out right now.
pendingintegerCollected but not yet available for payout.
totalintegeravailable + pending: what we hold for you in this currency.
cachedintegerOur cached figure. Normally identical to total.
consistentbooleanfalse means our cached balance disagrees with our journal. The figures above are still the journal totals and remain authoritative. Tell us if you see this.

Amounts are in minor units, like everywhere else in this API. Currencies are always reported separately and never summed together.

How your balance moves & how to reconcile

Your Samsoftpay balance is a real ledger we hold for you. It goes up when you collect and down when you pay out — every change is atomic and can never overdraft:

EventEffect on your balance
Charge succeeds (money in)+ (amount − 1.5% fee) into pending, then to available after the settlement hold.
Payout / refund (money out)− (amount + 1.5% fee) from available. A failed payout refunds amount + fee back.
Withdrawal to your own MoMo− (amount + 1.5% fee) from available (needs a verified account).

Keeping your app in sync (the reconciliation model):

  • GET /v1/balance is the source of truth. If your app keeps its own mirror balance or per-user wallets, they must reconcile to this figure.
  • Update on webhooks, verify with balance. Each state change fires a signed webhook — charge.succeeded raises your mirror, payout.succeeded/payout.failed adjusts it — so you don't have to poll. Treat webhooks as the nudge and GET /v1/balance (or GET /v1/charges / GET /v1/payouts) as the authority when they disagree.
  • Deductions are real-time. The moment a payout is accepted, available drops by amount + fee; your app reads the new figure immediately.
  • Fees & tax: the only Samsoftpay charge is the 1.5% fee (min UGX 200, cap UGX 5,000) on each charge and payout. The 0.5% Mobile-Money levy is deducted by MTN at the rail (not by us); URA VAT applies to fees. So reconcile against net (amount − fee), not gross.
Golden rule: your internal wallets/records reconcile to GET /v1/balance, mode-scoped by your key (a sk_test_ key shows sandbox only). If consistent is ever false, the journal totals shown are still authoritative — tell us.

Payouts

Send money out to a recipient's mobile money wallet. The merchant must have sufficient available balance.

A 1.5% fee (min UGX 200, cap UGX 5,000) applies per payout — the same rate as collections, no flat fee. It is deducted from the merchant's available balance along with the payout amount; if the payout fails, both are fully refunded. (No other charges apply besides URA taxation where relevant.)

Create a Payout

POST /v1/payouts
ParameterTypeDescription
amount requiredintegerAmount in UGX to send.
channel optionalstring"mtn_momo" (default).
recipient.phone requiredstringRecipient's phone number.
recipient.name optionalstringRecipient's display name.
Python JavaScript
resp = requests.post(
    "https://samsoftpay.com/v1/payouts",
    headers={
        "Authorization": "Bearer sk_test_YOUR_TEST_KEY",
        "Idempotency-Key": str(uuid.uuid4()),
        "X-Timestamp": str(int(time.time())),
        "Content-Type": "application/json",
    },
    json={
        "amount": 50000,
        "currency": "UGX",
        "channel": "mtn_momo",
        "recipient": {"phone": "256780000001", "name": "Jane Doe"},
    }
)
print(resp.json())
# {"id": "pout_xyz789", "status": "authorized", "fee": 750, ...}
const resp = await fetch("https://samsoftpay.com/v1/payouts", {
  method: "POST",
  headers: {
    "Authorization": "Bearer sk_test_YOUR_TEST_KEY",
    "Idempotency-Key": crypto.randomUUID(),
    "X-Timestamp": String(Math.floor(Date.now() / 1000)),
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    amount: 50000,
    currency: "UGX",
    channel: "mtn_momo",
    recipient: { phone: "256780000001", name: "Jane Doe" },
  }),
});
X-Timestamp may be Unix seconds or milliseconds (Date.now() is fine) — we normalise it. The ±5-minute replay window applies to all POSTs.

Provider IDs & the "no id" case

An accepted payout returns a stable pout_… id immediately. It is immutable across pending → authorized → succeeded/failed, appears in every webhook (data.id), and is the key for GET /v1/payouts/{id}. A payout that reaches the rail keeps its id even when it later fails.

A request rejected before creation (bad request, wrong scope, or insufficient available balance) has no pout_… id and no record — read the error/failure_reason to see why, correct it and retry. A null id is a rejection, never a lost payout.

Payout status lifecycle

StatusMeaningFinal?Webhook
pendingcreated, funds reserved, not yet on the railno
authorizedaccepted by the MTN rail, in flightno
succeededrecipient paidyespayout.succeeded
failedrail rejected/failed — amount + fee refunded in fullyespayout.failed

States never move backward. An ambiguous network error parks the payout authorized (never a false failed) and is resolved from MTN's own answer — poll GET /v1/payouts/{id} as the source of truth.

Machine-readable failure reasons

failure_reason is a stable code (with a human message where relevant): recipient_not_found, wallet_locked, timeout, insufficient_funds, user_cancelled, insufficient_balance (merchant wallet), rail_rejected.

Bulk payouts (payroll)

POST /v1/payouts/bulk

Up to 1000 items, per-item (not atomic — some items may succeed while others fail). Idempotency-Key is required; each item also dedupes on its reference. The array root may be payouts or items, and each item uses the same recipient:{phone,name} shape as the single endpoint.

POST /v1/payouts/bulk
{ "payouts": [
  { "amount": 50000, "recipient": { "phone": "256780000001", "name": "Emp One" }, "reference": "PR-001" },
  { "amount": 75000, "recipient": { "phone": "256780000002", "name": "Emp Two" }, "reference": "PR-002" }
] }

// -> per-item results, each correlatable by reference + provider id + status
{ "batch_id": "batch_…", "total": 2, "accepted": 2, "failed": 0, "results": [
  { "index": 0, "ok": true, "id": "pout_…", "status": "authorized", "reference": "PR-001" },
  { "index": 1, "ok": true, "id": "pout_…", "status": "authorized", "reference": "PR-002" }
] }

Payout webhooks

Configure a webhook URL on Account → Webhooks to receive payout.succeeded / payout.failed. Each event carries a stable evt_… id (dedupe on it), the pout_… id, your reference, status, amount, fee, currency and failure_reason — HMAC-signed exactly like charge events (see Verifying Webhooks).

Gift Cards & Vouchers

Gift cards are a merchant-issued store-credit. They are created and managed in your dashboard, and redeemed by the customer on the hosted checkout — there is deliberately no API endpoint to redeem one (a gift card is your own liability, not something an integrator's server should be able to spend).

How it works

  • Issue: Dashboard → Gift Cards → create a card with a balance and currency. You get a code to hand to the customer.
  • Redeem: on the hosted checkout (/pay/<id>), the customer enters the code in the "Have a voucher?" field. It's validated, then applied to the amount; the customer pays only the remainder (if any) by Mobile Money. A fully-covered order needs no rail payment at all.
  • Where it works: anywhere the customer lands on our hosted checkout — payment links, WooCommerce, Shopify, vending. It does not apply to the direct POST /v1/charges API.

Gift cards are real, live store credit (there is no sandbox gift-card system) — a sandbox (sk_test_) checkout is refused a gift card by design.

Refunds

POST /v1/charges/<id>/refund

Returns money to the customer's mobile-money account via a disbursement. The customer is refunded the full original amount (a UGX 5,000 charge refunds UGX 5,000) — the platform gives its charge fee back to the merchant, so the merchant's net cost of a refund is only the payout fee, never the customer's money. Requires a full secret key and X-Timestamp. It is idempotent by design (no Idempotency-Key needed): a retry of an already-refunded charge returns 400 already_refunded.

HTTP 202
{
  "charge_id": "txn_...",
  "status": "refunded",
  "refund": { "id": "pout_...", "amount": 5000, "status": "authorized" }
}

The 202 means the refund was initiated, not yet paid. Like any payout, the customer's money-out lands on the rail moments later — treat refund.status as a payout status and confirm it reaches succeeded (poll GET /v1/payouts/<id> or watch the payout.succeeded / payout.failed webhook), not as final on the 202.

Refunding twice returns 400 {"error": "already_refunded"}. A charge that was created with a split cannot be refunded through this endpoint yet. You will receive 400 {"error": "split_charge_refunds_not_yet_supported"}; contact support to reverse a split charge.

A refund draws from your available balance, and needs amount + the payout fee. A charge that just succeeded sits in the settlement hold (pending), and its own net proceeds (amount − charge fee) are less than a refund costs — so a charge cannot fund its own refund from its proceeds alone. Refunding immediately can also return 400 insufficient available balance even though the customer clearly just paid; this is correct — settled funds back the disbursement. Keep working float to cover same-day refunds.
Vending use case (a jammed sale): money is collected up front, so if the machine then reports vending.dispense_failed, refund the charge named in that event with this same endpoint — the customer gets back the full amount they paid. No request body and no Idempotency-Key are needed; the refund is idempotent by design. A refired request for an already-refunded charge returns 400 already_refunded, never a second payout.

Listing & Search

GET /v1/charges  ·  GET /v1/payouts

Newest first, cursor pagination. Use this to reconcile after a missed webhook. You never need to have stored an id to find a payment again.

GET /v1/charges?limit=20&status=succeeded&created_after=2026-08-01T00:00:00Z

{
  "object": "list",
  "data": [ { ...same shape as GET /v1/charges/<id>... } ],
  "has_more": true,
  "next_cursor": "txn_9f2c41d8a3b7e615"   // pass as ?starting_after=
}

Filters: status, reference (charges only), phone, email (reconcile by customer), created_after / created_before (ISO 8601), limit (1–100). phone matches on the last 9 digits, so 0780…, 256780… and +256 780… all find the same customer. Lists are scoped to your key's mode: a test key only ever sees test data. Failed charges carry failure_reason (e.g. insufficient_funds) on every charge object.

Subaccounts & Split Payments

For platforms settling money to multiple sub-merchants (vending operators, marketplace sellers, wallet users): register subaccounts, then split any charge.

POST /v1/subaccounts
{ "name": "Shop A", "payout_phone": "256772123456", "external_ref": "your-id-42" }
-> 201 { "id": "sub_...", "name": "Shop A", "status": "active" }

Then add split to a charge. Shares are fixed amounts or basis points of the net (amount minus our fee). Whatever the shares don't consume stays with you (the platform):

POST /v1/charges
{
  "amount": 100000, "channel": "mtn_momo",
  "customer": {"phone": "256700000000"},
  "split": [
    {"subaccount": "sub_a", "amount": 30000},
    {"subaccount": "sub_b", "bps": 2000}
  ]
}

Each share settles into its subaccount's own balance after the standard hold. See it on GET /v1/charges/<id> (a split array) and GET /v1/subaccounts/<id> (per-currency balances). An over-allocated split (shares exceeding the net) is rejected with 400 and nothing is created.

Vending Machines

Take Mobile Money on an unattended machine. You create an order, the machine shows the QR we return, the customer scans and pays on their phone, and we tell the machine to release the product the moment the payment succeeds. Your machine software never handles money.

Enable it first: Dashboard → Vending. While it is off, no order can be created and no machine will be told to dispense.

Bringing your own machine? (Machine Integration Standard v1) Any machine can integrate — not just one supplier. After a payment we send a signed dispense command, and your machine reports the real result to a signed callback (MD5(secret + timestamp + reqData), fields sorted alphabetically). Your callback URL for the supplier is https://api.samsoftpay.com/inbound/xy/dispense-result. Which secret? any of: (1) the dedicated dispense-result secret you generate via Dashboard → Vending → Dispense-result callback secret (or POST /v1/vending/dispense-secret); shown once; can only sign callbacks, never touch the API — recommended for locally-dispensing machines that embed a secret in firmware; (2) your XY operator secret; or (3) your API secret / collections key for the order's mode — what a machine embedding its own key already uses, so no firmware change is ever required. The dedicated secret is the recommended production credential; an API/collections key works for development but should never be embedded on a public kiosk. You certify against us before go-live: post a sample signed callback to POST /v1/vending/conformance ({ "payload": {…}, "sign": "…" }) and we reply { "ok": true, "secret_kind": "…" } (naming which accepted secret your sample verified against) or show the exact reqData bases we expected — iterate until it passes. It moves no money and dispenses nothing. Your vendor signing profile (which fields are signed, key ordering, replay window) is configuration on our side, not a code change — so onboarding a new machine never touches the platform. A 5-minute replay window is enforced: timestamp may be 13-digit epoch-milliseconds or 10-digit epoch-seconds (we accept both); the ±5 minute freshness check closes a replay-captured callback hole that no signed-field fix alone can address.
XY Supplier Integration — what your callback must look like Your machine dispenses independently. After it finishes, your cloud sends us a signed callback. Our API does the rest — you do not need to understand our internals, just send the right fields. Here is everything you need.

1. The callback endpoint

POST https://api.samsoftpay.com/inbound/xy/dispense-result
Content-Type: application/json
No login or token — the body's sign is the authentication.

2. The fields we need

All fields must be at the top level of the JSON (flat, not nested). The two most important ones are ddbh and dsfjybh — our order id and charge id. If your cloud receives our ApplyExportGoods request, store those values and echo them back. If your machine dispenses independently and your cloud never receives them, we can still match the callback by jqbh (your machine number) — we find the most recent pending order for that machine automatically.
FieldSourceWhat it means
ddbh best-effortfrom ApplyExportGoodsOur order id (lnk_...). Echo verbatim if you have it.
dsfjybh best-effortfrom ApplyExportGoodsOur charge id (txn_...). Echo verbatim if you have it.
jqbh requiredyour machineYour machine identifier. Used as fallback to match the order.
status requiredmachine result"1" / "finish" / "finished" = done.
splist requiredper-product resultArray — each item has spbh (product no) and chsl (units dispensed: "1" = delivered, "0" = jam).
timestamp requirednow13-digit epoch-ms (10-digit s also accepted). Must be fresh (within 5 min).
sign requiredcomputedMD5(secret + timestamp + reqData) — see signing section below.
tkje optionalyour refund noteAdvisory only — never changes our outcome.

3. What the fields mean (outcome rules)

  • status finished + chsl ≥ 1 → delivered (order marked "dispensed").
  • status finished + chsl = 0 → jam (order marked "failed"; customer refunded if merchant opted in).
  • status not finished → recorded as "order not finished" (no status change).

4. How to sign

sign = MD5(secret + timestamp + reqData) (lowercase hex)
reqData = every top-level SCALAR field except sign, key, timestamp, and splist, sorted alphabetically, joined as k=v with &.
Accepted alternative spellings: status = state, dsfshdh = dsfshbh.
Which secret? any of: (1) the dedicated dispense-result secret (recommended); (2) your XY operator secret; or (3) your Samsoftpay API secret / collections key for the order's mode.

5. Sample payload

{
  "jqbh": "txyz00123",
  "shbh": "001",
  "paytype": "forwardPayCode",
  "status": "1",
  "ddbh": "lnk_3cc4668c388f4f00",
  "dsfjybh": "txn_...",
  "zfzh": "256783647260",
  "payAmount": 2500,
  "tkje": 0,
  "timestamp": 1789012345678,
  "splist": [
    {"spbh": "0001", "spmc": "Coca-Cola 500ml", "chsl": "1"}
  ],
  "sign": "<computed>"
}

Note: if your cloud does not relay ApplyExportGoods to the machine, ddbh and dsfjybh may be empty — that is fine. We match by jqbh (machine number) against the most recent pending order for that machine.

6. Test your signature

Post a sample signed callback to POST /v1/vending/conformance (with your API key). We reply {"ok": true, "secret_kind": "..."} if it verifies, or show the expected bases so you can iterate. It moves no money and dispenses nothing.
Order/QR lifetime, invalidation, jams & refunds
  • QR lifetime: a vending order QR expires 2 minutes after it is created (configurable). An unpaid QR then stops working — the checkout page shows "This order has expired", the pay endpoint is refused, and the machine's state.json reports expired: true so the screen can clear. Ordinary payment links do not expire. Once the customer taps pay, the mobile-money PIN prompt has the network's own window (about 60 seconds).
  • Invalidate a QR immediately: the 2-minute expiry is a cap, not instant. Kill a wrong/mistyped QR now with POST /v1/vending/orders/<order_id>/invalidate (409 if a payment already attached; collections keys are allowed — it only stops money coming in). The checkout page, pay endpoint and machine screen treat the order as dead right away.
  • Jam detection: charge.succeeded means the money arrived, not that the product came out. Your dispense-result callback reports the units actually dispensed (chsl); a jam (finished, zero units) flips the order to failed and we emit vending.dispense_failed.
  • Refunds: WE own the rails: the customer pays us (Mobile Money), so we refund the customer. The supplier's own ledger note (tkje) is advisory context only and never blocks a refund, because the operator never holds the payer's money. A genuine zero-unit jam refunds from our ledger automatically when the merchant has opted into automatic refund-on-jam; otherwise call POST /v1/charges/<id>/refund yourself when a vending.dispense_failed event arrives — spelled out in the Refunds section. Off by default — ask us to enable it for your account.
POST /v1/vending/orders
ParameterTypeDescription
machine requiredstringMachine number from your machine operator.
amount requiredintegerWhat the customer pays, in whole UGX.
goods requiredarrayItems to release: spbh (slot/product number), spmc (name), spdj (unit price). Passed to the machine unchanged.
reference optionalstringYour own order id.
resp = requests.post(
    "https://samsoftpay.com/v1/vending/orders",
    headers={
        "Authorization": "Bearer sk_test_YOUR_TEST_KEY",
        "Idempotency-Key": str(uuid.uuid4()),
        "X-Timestamp": str(int(time.time())),
        "Content-Type": "application/json",
    },
    json={
        "machine": "XY000123",
        "amount": 2500,
        "goods": [{"spbh": "0001", "spmc": "Coca-Cola 500ml", "spdj": 2500}],
    }
)
order = resp.json()
order["display_url"]  # open this on the machine screen — it shows the QR and
                      # switches itself to "collect your item" when done
order["qr_png_url"]   # or draw the QR yourself from order["qr_content"]
GET /v1/vending/orders/{order_id}

Poll an order if you would rather drive the screen yourself. Returns payment_status (unpaid, pending, succeeded, failed) and vending_status (pending, dispensing, dispensed, failed).

POST /v1/vending/orders/{order_id}/dispense

Retry a dispense that failed, for example because the machine was offline when the payment landed. The customer's money is never at risk: a retry only works on an order whose charge actually succeeded, and an order can never dispense twice.

GET /v1/vending/machines
GET /v1/vending/machines/{machine}/goods

Your machines, and the live tray contents and prices of one machine. Build your on-screen menu from this rather than hard-coding slots.

GET /v1/vending/machines/tallies

Per-machine money and dispense tally — reconcile what your cloud recorded for each machine against what Samsoftpay actually captured. This is the vending counterpart of GET /v1/balance: same source of truth (the order's transaction, never a cache), so it always agrees with the webhook feed you already received.

# every machine in your registry, with zero-order machines included
curl -H "Authorization: Bearer sk_live_.." \
     https://api.samsoftpay.com/v1/vending/machines/tallies

# one machine / a date window, for a nightly reconcile job
curl -H "Authorization: Bearer sk_live_.." \
     "https://api.samsoftpay.com/v1/vending/machines/tallies?machine=1707600112&since=2026-09-01T00:00:00Z"

=> {
  "mode": "live",
  "as_of": 1755900000,
  "machines": [{
    "machine": "1707600112", "name": "Kampala lobby", "registered": true,
    "orders": 120, "paid": 118,
    "collected": 2950000,      # sum of customer charges on paid orders
    "fees": 23600,             # processing fees, minor units
    "settled": 2900000,        # already swept out of the 24h hold
    "pending_hold": 50000,     # still inside its hold window
    "refunded": 0,             # money later returned to a customer
    "dispensed": 117, "dispense_failed": 1, "cancelled": 2
  }]
}

All money fields are in minor units, mode-scoped exactly like the rest of the API: a sandbox key sees only sandbox orders (never reconcile test money against live floats). Registered machines always appear — even with zero orders — so your machine list lines up with ours 1:1. Note this is a report: settlement itself stays per-merchant, not per-machine.

Webhook Events

Samsoftpay POSTs a JSON payload to your webhook_url whenever a charge or payout changes state. We retry up to 8 times with exponential backoff.

Respond with any 2xx status code within 5 seconds to acknowledge receipt.

Point the webhook URL at YOUR OWN backend — not at api.samsoftpay.com. On Account → Webhooks, set it to your public HTTPS endpoint, e.g. https://www.yourdomain.com/webhooks/samsoftpay (KarlPOS uses https://www.karlpos.com/webhooks/samsoftpay). It must be a publicly reachable HTTPS URL on a server you control — a deployed backend, never localhost or a private/internal address (those are rejected).

While you're testing you don't need your own endpoint yet. Leave the webhook URL as-is (the default Samsoftpay value is fine — nothing breaks, events simply queue) and just poll GET /v1/charges/<id> or GET /v1/payouts/<id> for the result. When you go live, switch the URL to your own server (e.g. https://www.karlpos.com/webhooks/samsoftpay) so you receive and verify events automatically. Webhooks are the push convenience, not a requirement for correctness.

The Envelope

Every delivery is one JSON envelope, sent as canonical JSON with no spaces (the signature covers these exact bytes). Shown pretty-printed here for readability:

{
  "id": "evt_1a2b3c4d5e6f7a8b9c0d1e2f",
  "timestamp": 1755900000,
  "event": "charge.succeeded",
  "data": {
    "id": "txn_abc123",
    "amount": 10000,
    "fee": 200,
    "currency": "UGX",
    "channel": "mtn_momo",
    "status": "succeeded",
    "merchant_reference": "order-001",
    "failure_reason": null,
    "completed_at": "2026-08-20T10:30:00+00:00"
  }
}

The envelope id is unique per event and retries share it, so dedupe on it. timestamp is Unix seconds; use it to enforce a replay window.

Event catalogue: charge.succeeded, charge.failed, payout.succeeded, payout.failed, vending.dispensed, vending.dispense_failed, dispute.opened (a customer used the public "Report a problem" link on a receipt. Respond within 72 hours; it never moves money by itself, and its details/contact fields are raw customer input: escape them before rendering), test.ping (the Account → Webhooks test button).

charge.succeeded means the money arrived, and nothing else. For vending, it does not mean the product came out: wait for vending.dispensed before treating the product as delivered.

Verifying Webhooks

Every request includes an X-Samsoftpay-Signature header: an HMAC-SHA256 of the raw request body, signed with your webhook signing secret (whsec_…), shown in Account → Webhooks. It is unique to your account. Always verify before processing.

Python (Flask) Node.js (Express)
import hmac, hashlib
from flask import request, abort

WEBHOOK_SECRET = "your_webhook_signing_secret"

@app.post("/webhooks/samsoftpay")
def handle_webhook():
    sig = request.headers.get("X-Samsoftpay-Signature", "")
    expected = hmac.new(
        WEBHOOK_SECRET.encode(),
        request.data,
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, sig):
        abort(400, "invalid signature")

    event = request.get_json()
    if event["event"] == "charge.succeeded":
        order_id = event["data"]["merchant_reference"]
        # mark order as paid in your database
        pass

    return {"ok": True}
const crypto = require("crypto");

app.post("/webhooks/samsoftpay", express.raw({ type: "application/json" }), (req, res) => {
  const sig = req.headers["x-samsoftpay-signature"];
  const expected = crypto
    .createHmac("sha256", process.env.WEBHOOK_SECRET)
    .update(req.body)
    .digest("hex");

  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
    return res.status(400).send("invalid signature");
  }

  const event = JSON.parse(req.body);
  if (event.event === "charge.succeeded") {
    // mark order as paid
  }
  res.json({ ok: true });
});
Verify over the raw request body exactly as received. Never parse and re-serialize the JSON first. Any change in key order or whitespace breaks the digest.

Webhook Operations & Retries

Respond with any 2xx within 5 seconds. Return 200 immediately and process asynchronously. A slow handler is indistinguishable from a dead one and will be retried. Retries carry the same envelope id, so dedupe on it, not on receipt count.

Retry schedule

The first attempt fires immediately. Each failure schedules the next attempt after a growing backoff, up to 8 attempts in total:

AttemptDelay after previous failure
1immediate
21 minute
35 minutes
430 minutes
52 hours
66 hours
712 hours
824 hours

So a delivery keeps retrying for roughly two days after the event before giving up. After that, resend it yourself (below) or reconcile by listing charges.

Inspect & resend deliveries

GET /v1/webhooks

Lists your most recent webhook deliveries, each with its event id (evt_…), event, status, attempts and last_response_code, so you can see exactly what we sent and how your endpoint answered.

POST /v1/webhooks/{event_id}/resend

Re-queues one delivery by its evt_… id and returns 202. The resent delivery carries the same envelope (same id), so your dedupe logic handles it like any retry.

Pre-flight a payout destination

GET /v1/resolve-account?phone=0771234567

MTN's own answer about a wallet before any money is earmarked. Returns msisdn, active and registered_name. Treat active: false as the hard stop; the name is the double-check (it can be null while our MTN KYC scope is pending). Sandbox is deterministic: 256700000001 resolves inactive, every other number is an active SANDBOX HOLDER. Full-scope keys only: a kiosk credential must not be able to enumerate wallet owners.

Egress IPs (only if your endpoint is IP-firewalled)

Verify the signature, don't rely on IP. The reliable way to trust a webhook is the X-Samsoftpay-Signature HMAC (see Verifying Webhooks) — it proves the request is really from us and can't be spoofed. That is all most integrators need.

Only if your webhook endpoint sits behind an IP allowlist do you need our outbound ranges. These are the source addresses our servers send from — they are not websites, so you can't open them in a browser. They can change if we move hosting region, so always keep signature verification as your primary check; treat the allowlist as a convenience, not the security boundary. Current outbound ranges:

Range
74.220.48.0/24
74.220.56.0/24

Integration Best Practices

RuleWhy
Give value only on a definite outcome. pending / authorized is not failed. An ambiguous network answer parks a charge, it never fails it. Release goods or credit a wallet only when status is succeeded.
Confirm before fulfilling. Via the charge.succeeded webhook or GET /v1/charges/<id>, never from a client-side signal alone.
Verify amount, currency and reference. Check the confirmed charge matches the order you are about to fulfil, before delivering value.
Prefer webhooks over polling. Poll as a fallback; reconcile with GET /v1/charges and GET /v1/balance.
Dedupe on the envelope id. Retries and resends share it, so process each event once.
Never embed a full sk_ key on a device. Use a collections-only sk_*_col_ key on kiosks and apps — it cannot call payouts or refunds.
Never log full keys; TLS only. Keep keys and webhook secrets in environment variables; serve your webhook endpoint over HTTPS.

Error Catalog

Every error the API returns is JSON with a single error field, listed here verbatim (dynamic parts shown as ). The Retry? column is the contract: yes, same key means retry with the same Idempotency-Key; no, fix request means change something first; no, permanent means retrying will never help.

# Error response shape
{"error": "insufficient available balance: have 5000, need 50750 (amount 50000 + fee 750)"}

Authentication & headers

ErrorHTTPCauseRetry?
missing bearer token401No Authorization: Bearer <key> header.no, fix request
invalid api key401Unknown, rotated or revoked key.no, fix request
this endpoint requires a full secret key; collections-only keys cannot move money out403An sk_*_col_ key on POST /v1/payouts, /v1/payouts/bulk or /v1/charges/<id>/refund. Fires on scope before any lookup.no, call from your server with a full key
X-Timestamp header required. …400Missing X-Timestamp on a POST.no, fix request
X-Timestamp must be an integer Unix timestamp400Non-integer value (e.g. milliseconds with a decimal point, or ISO text).no, fix request
request timestamp is …s old — max allowed skew is 300s400Timestamp older than 5 minutes (replay protection).yes, with a fresh timestamp
request timestamp is too far in the future — check your system clock400More than 60s ahead of our clock.yes, after fixing your clock

Idempotency & rate limits

ErrorHTTPCauseRetry?
Idempotency-Key header required400Missing on a money POST (charges, payouts, bulk, refund).no, fix request
idempotency key reused with different request body409Same key, different payload. A key names one logical operation.no, use a new key for new work
a request with this Idempotency-Key is still in flight — retry shortly409The original request with this key has not finished yet (keys are reserved before execution, so concurrent duplicates can never both run).yes, same key, shortly
rate limit message429Too many requests. Defaults: charges 120/min, payouts 30/min, refunds 10/min (all configurable per deployment).yes, back off and honour the Retry-After header

Charges

ErrorHTTPCauseRetry?
invalid request: …400Malformed body: missing amount, bad channel, split not a list, etc.no, fix request
amount must be positive400Zero or negative amount.no, fix request
amount exceeds the maximum of …400Amount above the configured per-transaction ceiling.no, fix request
demo only supports UGX400A currency other than UGX.no, fix request
merchant is not active400Your account is deactivated.no, permanent until support reactivates
live charges require a verified business — complete verification on your dashboard (test keys work immediately)400An sk_live_ key before KYC verification. Zero writes.no, verify first; test keys work now
… is not available for live payments yet400A channel with no real rail (airtel_money, card) on a live key. A simulated rail must never settle live money. Zero writes.no, use mtn_momo live, or a test key
fee exceeds amount400Amount too small to cover the minimum fee (UGX 200).no, fix request
invalid split: …400Bad split array: unknown/inactive/duplicate subaccount, shares exceeding the net, bad amount/bps. Zero writes.no, fix request
payment rail temporarily unavailable — retry with the same Idempotency-Key502Transient rail/network failure before anything was recorded. Deliberately not cached against your key.yes, same key

Payouts & refunds

ErrorHTTPCauseRetry?
live payouts require a verified business — complete verification on your dashboard (test keys work immediately)400An sk_live_ key before KYC verification. Zero writes.no, verify first
insufficient available balance: have …, need … (amount … + fee …)400Available (settled) balance below amount + fee. Pending money does not count until it settles.no, top up or wait for settlement, then a new key
no disbursement adapter for channel …400Payout channel with no disbursement rail (e.g. airtel_money). Rejected before any money moves.no, use mtn_momo
payouts are temporarily paused platform-wide for a safety review — no action needed on your side; money in is unaffected400Platform payout freeze during a security/safety event. Zero writes.no, wait; money in unaffected
payouts are paused on this account while a payment reconciliation issue is investigated — support has been notified; your balance is safe and money in is unaffected400An open critical reconciliation exception on your account pauses your live payouts until resolved.no, wait for support
disbursement rail unavailable: …400The rail failed cleanly before the transfer was sent. Nothing left our side.yes, after the outage, with a new key (this response is cached against the old one)
no payout items provided (JSON {payouts:[...]} or CSV) / batch too large (max 1000 items per call)400Bulk payout body empty or over 1000 items.no, fix request
already_refunded400The charge was already refunded; refunds happen once.no, permanent
cannot_refund_…_transaction400Refund on a charge that is not succeeded (e.g. cannot_refund_pending_transaction).no, only succeeded charges refund
split_charge_refunds_not_yet_supported400The charge was created with a split. Split refunds are deliberately not enabled yet; contact support to reverse one. Zero writes.no, permanent (for now)
mode_mismatch: this is a … charge — use your … key to refund it400Refunding a test charge with a live key or vice versa. Zero writes.no, use the key of the matching mode

Vending

ErrorHTTPCauseRetry?
vending is not enabled for this merchant403Dashboard → Vending is switched off.no, enable it first
machine not registered to this merchant404Unknown machine number for your account.no, fix request
cannot dispense: charge status is …, not succeeded400Dispense attempted against a charge that has not succeeded. A machine dispenses only against collected money.no, wait for succeeded
this charge has already paid for a dispense409One succeeded charge pays for exactly one dispense.no, permanent

Asynchronous failures (not HTTP errors)

A charge or payout that is accepted (HTTP 201) can still fail later on the rail. That outcome arrives as "status": "failed" with a failure_reason, via webhook (charge.failed / payout.failed) or polling. Reproduce each one deterministically in the sandbox with a magic number (see Testing):

failure_reasonWhereTest number that reproduces it
insufficient_fundschargecustomer phone 256700000001
user_cancelledchargecustomer phone 256700000002
timeoutchargecustomer phone 256700000003
recipient_not_foundpayoutrecipient phone 256700000001
wallet_lockedpayoutrecipient phone 256700000002
timeoutpayoutrecipient phone 256700000003

A failed payout refunds the amount and the fee to your available balance. A 404 anywhere means the resource does not exist, belongs to another merchant, or belongs to the other mode (test vs live) than your key.

Go-Live Checklist

The path from first sandbox charge to real money, in order. Every step is checkable. Do not skip the config-drift checks: they catch the classic "worked in test, silently broken in live" failures.

  1. Build in the sandbox. Integrate with your sk_test_ key. Sandbox money lives on a separate ledger. Nothing you do here can touch real balances.
  2. Test failure paths in both directions with the magic numbers. Charges: customer phones 256700000001/2/3 fail with insufficient_funds / user_cancelled / timeout. Payouts: the same numbers as the recipient fail with recipient_not_found / wallet_locked / timeout. Confirm your handlers for charge.failed and payout.failed, your webhook signature verification, and your dedupe on the envelope id.
  3. Verify your business (KYC) on the dashboard. Live charges and payouts are refused with 400 until your account is verified. Test keys keep working throughout.
  4. Config-drift checks before switching keys:
    • Live webhook URL set and verified. Use the Send test event button on Account → Webhooks, which POSTs a signed test.ping event to your endpoint; confirm your handler verifies the signature and returns 2xx.
    • Live key issued and stored server-side only (an environment variable, never in client code, mobile apps or repos).
    • Kiosk and vending devices carry sk_live_col_ collections-only keys, never full keys. A key pulled off a device must not be able to move money out.
  5. First live charge, small amount. Verify it via GET /v1/charges/<id> and confirm the charge.succeeded webhook arrived and verified. Both paths must work before volume.
  6. First live settlement confirmed on GET /v1/settlements. After the 24h hold (hourly sweep), the release appears as a settlement record and your available balance on GET /v1/balance moves. Now you know money in, money held and money withdrawable all agree.
  7. Subscribe to the changelog. API changes land there first; response shapes are additive-only and webhook events are only ever added, so reading it is routine maintenance.