API Documentation
Everything you need to integrate Samsoftpay into your application.
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).
| Mode | You're building | Customer 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 |
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.
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.
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
| Credential | Direction | Used for | Where you find it |
|---|---|---|---|
sk_live_… / sk_test_… | proves you → us | Authorization: Bearer on every API call | Your Account page |
whsec_… | verifies us → you | Checking X-Samsoftpay-Signature on incoming webhooks | Account → Webhooks (shown masked) |
sk_ and whsec_./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
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 forceinsufficient_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.
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:
- Base URL:
https://api.samsoftpay.com(same for test and live). - Your API key:
sk_test_…from a free account (instant, no approval), sent asAuthorization: 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.
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
Required Headers
All POST requests require these additional headers:
| Header | Description |
|---|---|
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
| Parameter | Type | Description |
|---|---|---|
amount required | integer | Amount in UGX (minor units). Must be > 0. |
currency optional | string | Currently only "UGX". Default: "UGX". |
channel required | string | "mtn_momo" (live). "airtel_money" and "card" work with test keys only — live charges on them are refused until those rails launch. |
customer.phone required | string | Customer's phone number. E.g. "256700123456". |
reference optional | string | Your internal order/reference ID. |
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"}}'
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
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 number | Result |
|---|---|
256700000000 | Always succeeds (same as any ordinary number, documented for clarity) |
256700000001 | Fails with insufficient_funds |
256700000002 | Fails with user_cancelled |
256700000003 | Fails with timeout |
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 number | Result |
|---|---|
256700000000 | Always succeeds |
256700000001 | Fails with recipient_not_found |
256700000002 | Fails with wallet_locked |
256700000003 | Fails 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.
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.
sk_test_ key returns your sandbox balance, which is not real money. Never reconcile real liabilities against a sandbox figure.Retrieve your 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
# }
| Field | Type | Description |
|---|---|---|
available | integer | Settled funds you can pay out right now. |
pending | integer | Collected but not yet available for payout. |
total | integer | available + pending: what we hold for you in this currency. |
cached | integer | Our cached figure. Normally identical to total. |
consistent | boolean | false 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:
| Event | Effect 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/balanceis 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.succeededraises your mirror,payout.succeeded/payout.failedadjusts it — so you don't have to poll. Treat webhooks as the nudge andGET /v1/balance(orGET /v1/charges/GET /v1/payouts) as the authority when they disagree. - Deductions are real-time. The moment a payout is accepted,
availabledrops 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.
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.
Create a Payout
| Parameter | Type | Description |
|---|---|---|
amount required | integer | Amount in UGX to send. |
channel optional | string | "mtn_momo" (default). |
recipient.phone required | string | Recipient's phone number. |
recipient.name optional | string | Recipient's display name. |
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" },
}),
});
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
| Status | Meaning | Final? | Webhook |
|---|---|---|---|
pending | created, funds reserved, not yet on the rail | no | — |
authorized | accepted by the MTN rail, in flight | no | — |
succeeded | recipient paid | yes | payout.succeeded |
failed | rail rejected/failed — amount + fee refunded in full | yes | payout.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)
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).
Accept a Payment (Hosted Checkout)
The simplest way to take money — and the one most stores want. You create a
payment link, send the customer to the returned url (or show its QR on a screen). They
pick their method and pay on our secure page — you never handle a card number or a MoMo PIN,
so you carry no PCI scope. This is the exact flow the WooCommerce plugin,
the Shopify app, and TK's vending machines all use.
The flow (three steps)
1. POST /v1/payment-links → { id, url, qr_png_url }
2. Send the customer to `url` (redirect their browser, or show the QR)
→ they pick MTN / Airtel / card / crypto and pay on our page
3. Confirm the payment: charge.succeeded webhook OR GET /v1/charges/<id>
→ then fulfil the order / dispense the product
| Parameter | Type | Description |
|---|---|---|
amount required | integer | Amount in the smallest unit (UGX has none, so whole shillings). |
currency optional | string | Default UGX. |
description optional | string | Shown to the customer on the payment page. |
reference optional | string | Your order id. Echoed on the charge and every webhook — match on it to reconcile. |
success_url optional | string | Where the customer returns after paying. |
cancel_url optional | string | Where they return if they cancel. |
allow_multiple_uses optional | boolean | If true, the link can be paid more than once (a reusable "donate" page). Default false — a one-shot order link. |
resp = requests.post(
"https://samsoftpay.com/v1/payment-links",
headers={
"Authorization": "Bearer sk_test_YOUR_TEST_KEY",
"Content-Type": "application/json",
"Idempotency-Key": str(uuid.uuid4()),
"X-Timestamp": str(int(time.time())),
},
json={
"amount": 25000,
"reference": "order-1042",
"description": "School Fees Payment",
"success_url": "https://yourapp.com/thank-you",
"cancel_url": "https://yourapp.com/cart",
},
)
data = resp.json()
# → send the browser here, or render the QR on a machine screen:
print(data["url"]) # https://samsoftpay.com/pay/lnk_…
print(data["qr_png_url"]) # a ready PNG of that same URL (no auth)
Where the customer returns
success_url and cancel_url are your return pages. After
paying, the customer is offered a "Return to <you>" button to success_url; if you
set neither, they stay on our hosted status page. Never trust the return redirect as proof of
payment — always confirm server-side with the charge.succeeded webhook or
GET /v1/charges/<id> (a customer can close the tab before returning).
The customer picks the channel — you don't
You never send a channel when creating a link. Our page shows the methods that are live (MTN Mobile
Money today; Airtel Money and cards as they launch) and the customer chooses. Which method actually ran
comes back on the charge's channel field.
Gift cards & vouchers work on this page
Because the customer pays on our hosted checkout, they can apply a gift-card / voucher code right
there (a "Have a voucher?" field). This works for payment links, WooCommerce and Shopify — anything that
lands on the hosted checkout. It does not apply to the raw POST /v1/charges
API. See Gift Cards & Vouchers.
Checking the status
Poll GET /v1/charges/<id>, or wait for the webhook. The status field moves through:
| Status | Meaning | Fulfil? |
|---|---|---|
pending | Link created, not yet paid | No |
authorized | Prompt sent, awaiting the customer's PIN | No |
succeeded | Money received | Yes |
failed | Declined / cancelled / timed out | No |
refunded | Reversed after success | — |
Only release goods on succeeded. In the sandbox (sk_test_)
a charge auto-completes with no real PIN or money — see Simulate or real?
https://samsoftpay.com/pay/lnk_…) is one checkout for one amount. Your
public storefront / profile page (https://samsoftpay.com/pay/@yourhandle) is a
catalog that lists your items and links — a different thing. Send a link for one order; send the
storefront to browse.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/chargesAPI.
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.
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.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.
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.1. The callback endpoint
POST https://api.samsoftpay.com/inbound/xy/dispense-resultContent-Type: application/jsonNo 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 areddbh 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.
| Field | Source | What it means |
|---|---|---|
ddbh best-effort | from ApplyExportGoods | Our order id (lnk_...). Echo verbatim if you have it. |
dsfjybh best-effort | from ApplyExportGoods | Our charge id (txn_...). Echo verbatim if you have it. |
jqbh required | your machine | Your machine identifier. Used as fallback to match the order. |
status required | machine result | "1" / "finish" / "finished" = done. |
splist required | per-product result | Array — each item has spbh (product no) and chsl (units dispensed: "1" = delivered, "0" = jam). |
timestamp required | now | 13-digit epoch-ms (10-digit s also accepted). Must be fresh (within 5 min). |
sign required | computed | MD5(secret + timestamp + reqData) — see signing section below. |
tkje optional | your refund note | Advisory only — never changes our outcome. |
3. What the fields mean (outcome rules)
statusfinished +chsl≥ 1 → delivered (order marked "dispensed").statusfinished +chsl= 0 → jam (order marked "failed"; customer refunded if merchant opted in).statusnot 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 toPOST /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.- 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.jsonreportsexpired: trueso 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.succeededmeans 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 tofailedand we emitvending.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 callPOST /v1/charges/<id>/refundyourself when avending.dispense_failedevent arrives — spelled out in the Refunds section. Off by default — ask us to enable it for your account.
| Parameter | Type | Description |
|---|---|---|
machine required | string | Machine number from your machine operator. |
amount required | integer | What the customer pays, in whole UGX. |
goods required | array | Items to release: spbh (slot/product number), spmc (name), spdj (unit price). Passed to the machine unchanged. |
reference optional | string | Your 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"]
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).
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.
Your machines, and the live tray contents and prices of one machine. Build your on-screen menu from this rather than hard-coding slots.
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.
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.
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 });
});
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:
| Attempt | Delay after previous failure |
|---|---|
| 1 | immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 12 hours |
| 8 | 24 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
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.
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
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
| Rule | Why |
|---|---|
| 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
| Error | HTTP | Cause | Retry? |
|---|---|---|---|
missing bearer token | 401 | No Authorization: Bearer <key> header. | no, fix request |
invalid api key | 401 | Unknown, rotated or revoked key. | no, fix request |
this endpoint requires a full secret key; collections-only keys cannot move money out | 403 | An 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. … | 400 | Missing X-Timestamp on a POST. | no, fix request |
X-Timestamp must be an integer Unix timestamp | 400 | Non-integer value (e.g. milliseconds with a decimal point, or ISO text). | no, fix request |
request timestamp is …s old — max allowed skew is 300s | 400 | Timestamp older than 5 minutes (replay protection). | yes, with a fresh timestamp |
request timestamp is too far in the future — check your system clock | 400 | More than 60s ahead of our clock. | yes, after fixing your clock |
Idempotency & rate limits
| Error | HTTP | Cause | Retry? |
|---|---|---|---|
Idempotency-Key header required | 400 | Missing on a money POST (charges, payouts, bulk, refund). | no, fix request |
idempotency key reused with different request body | 409 | Same 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 shortly | 409 | The 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 message | 429 | Too 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
| Error | HTTP | Cause | Retry? |
|---|---|---|---|
invalid request: … | 400 | Malformed body: missing amount, bad channel, split not a list, etc. | no, fix request |
amount must be positive | 400 | Zero or negative amount. | no, fix request |
amount exceeds the maximum of … | 400 | Amount above the configured per-transaction ceiling. | no, fix request |
demo only supports UGX | 400 | A currency other than UGX. | no, fix request |
merchant is not active | 400 | Your account is deactivated. | no, permanent until support reactivates |
live charges require a verified business — complete verification on your dashboard (test keys work immediately) | 400 | An sk_live_ key before KYC verification. Zero writes. | no, verify first; test keys work now |
… is not available for live payments yet | 400 | A 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 amount | 400 | Amount too small to cover the minimum fee (UGX 200). | no, fix request |
invalid split: … | 400 | Bad 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-Key | 502 | Transient rail/network failure before anything was recorded. Deliberately not cached against your key. | yes, same key |
Payouts & refunds
| Error | HTTP | Cause | Retry? |
|---|---|---|---|
live payouts require a verified business — complete verification on your dashboard (test keys work immediately) | 400 | An sk_live_ key before KYC verification. Zero writes. | no, verify first |
insufficient available balance: have …, need … (amount … + fee …) | 400 | Available (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 … | 400 | Payout 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 unaffected | 400 | Platform 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 unaffected | 400 | An open critical reconciliation exception on your account pauses your live payouts until resolved. | no, wait for support |
disbursement rail unavailable: … | 400 | The 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) | 400 | Bulk payout body empty or over 1000 items. | no, fix request |
already_refunded | 400 | The charge was already refunded; refunds happen once. | no, permanent |
cannot_refund_…_transaction | 400 | Refund on a charge that is not succeeded (e.g. cannot_refund_pending_transaction). | no, only succeeded charges refund |
split_charge_refunds_not_yet_supported | 400 | The 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 it | 400 | Refunding a test charge with a live key or vice versa. Zero writes. | no, use the key of the matching mode |
Vending
| Error | HTTP | Cause | Retry? |
|---|---|---|---|
vending is not enabled for this merchant | 403 | Dashboard → Vending is switched off. | no, enable it first |
machine not registered to this merchant | 404 | Unknown machine number for your account. | no, fix request |
cannot dispense: charge status is …, not succeeded | 400 | Dispense 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 dispense | 409 | One 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_reason | Where | Test number that reproduces it |
|---|---|---|
insufficient_funds | charge | customer phone 256700000001 |
user_cancelled | charge | customer phone 256700000002 |
timeout | charge | customer phone 256700000003 |
recipient_not_found | payout | recipient phone 256700000001 |
wallet_locked | payout | recipient phone 256700000002 |
timeout | payout | recipient 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.
- 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. - Test failure paths in both directions with the magic numbers.
Charges: customer phones
256700000001/2/3fail withinsufficient_funds/user_cancelled/timeout. Payouts: the same numbers as the recipient fail withrecipient_not_found/wallet_locked/timeout. Confirm your handlers forcharge.failedandpayout.failed, your webhook signature verification, and your dedupe on the envelopeid. - Verify your business (KYC) on the dashboard. Live charges and
payouts are refused with
400until your account is verified. Test keys keep working throughout. - 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.pingevent 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.
- Live webhook URL set and verified. Use the Send test event
button on Account → Webhooks, which POSTs a signed
- First live charge, small amount. Verify it via
GET /v1/charges/<id>and confirm thecharge.succeededwebhook arrived and verified. Both paths must work before volume. - First live settlement confirmed on
GET /v1/settlements. After the 24h hold (hourly sweep), the release appears as a settlement record and youravailablebalance onGET /v1/balancemoves. Now you know money in, money held and money withdrawable all agree. - 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.