API Documentation
Everything you need to integrate Samsoftpay into your application.
Authentication
Samsoftpay uses Bearer token authentication. Pass your Secret Key in the Authorization header on every request.
import requests
headers = {
"Authorization": "Bearer sk_live_YOUR_SECRET_KEY",
"Content-Type": "application/json",
}
const headers = {
"Authorization": "Bearer sk_live_YOUR_SECRET_KEY",
"Content-Type": "application/json",
};
curl -H "Authorization: Bearer sk_live_YOUR_SECRET_KEY" \
https://yourapp.onrender.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_live_YOUR_SECRET_KEY",
"Idempotency-Key": str(uuid.uuid4()),
"X-Timestamp": str(int(time.time())),
"Content-Type": "application/json",
}
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", "airtel_money", or "card". |
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://api.samsoftpay.com/v1/charges",
headers={
"Authorization": "Bearer sk_live_YOUR_SECRET_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://api.samsoftpay.com/v1/charges", {
method: "POST",
headers: {
"Authorization": "Bearer sk_live_YOUR_SECRET_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://api.samsoftpay.com/v1/charges \
-H "Authorization: Bearer sk_live_YOUR_SECRET_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://api.samsoftpay.com/v1/charges/txn_abc123",
headers={"Authorization": "Bearer sk_live_YOUR_SECRET_KEY"}
)
# status: "pending" | "authorized" | "succeeded" | "failed"
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://api.samsoftpay.com/v1/payouts",
headers={
"Authorization": "Bearer sk_live_YOUR_SECRET_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://api.samsoftpay.com/v1/payouts", {
method: "POST",
headers: {
"Authorization": "Bearer sk_live_YOUR_SECRET_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" },
}),
});
Payment Links
Generate a shareable URL your customers open to pay — no integration required on their end. Perfect for WhatsApp, SMS, and email.
| Parameter | Type | Description |
|---|---|---|
amount required | integer | Amount in UGX. |
description optional | string | Shown to the customer on the payment page. |
success_url optional | string | Redirect URL after successful payment. |
allow_multiple_uses optional | boolean | If true, the link can be paid more than once. Default: false. |
resp = requests.post(
"https://api.samsoftpay.com/v1/payment-links",
headers={
"Authorization": "Bearer sk_live_YOUR_SECRET_KEY",
"Content-Type": "application/json",
},
json={
"amount": 25000,
"description": "School Fees Payment",
"success_url": "https://yourapp.com/thank-you",
}
)
data = resp.json()
print(data["url"]) # Share this link with your customer
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.
| 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://api.samsoftpay.com/v1/vending/orders",
headers={
"Authorization": "Bearer sk_live_YOUR_SECRET_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 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.
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.
Charge Events
{
"event": "charge.succeeded",
"data": {
"id": "txn_abc123",
"amount": 10000,
"fee": 200,
"currency": "UGX",
"channel": "mtn_momo",
"status": "succeeded",
"merchant_reference": "order-001",
"completed_at": "2024-01-15T10:30:00+00:00"
}
}
Possible event values: charge.succeeded, charge.failed.
Verifying Webhooks
Every request includes an X-Samsoftpay-Signature header — an HMAC-SHA256 of the raw request body signed with your WEBHOOK_SIGNING_SECRET. Always verify it 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 });
});
Errors
All errors return JSON with an error field.
| Status | Meaning |
|---|---|
400 | Bad request — missing field, invalid value, or stale X-Timestamp. |
401 | Unauthorized — missing or invalid Bearer token. |
404 | Resource not found or belongs to a different merchant. |
409 | Idempotency key reused with a different request body. |
429 | Rate limit exceeded. Charges: 30/min. Payouts: 10/min. |
# Error response shape
{"error": "insufficient available balance: have 5000, need 50750 (amount 50000 + fee 750)"}