Create a free account to see your live API keys pre-filled in all examples.

Authentication

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

Python JavaScript cURL
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
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_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

POST /v1/charges
ParameterTypeDescription
amount requiredintegerAmount in UGX (minor units). Must be > 0.
currency optionalstringCurrently only "UGX". Default: "UGX".
channel requiredstring"mtn_momo", "airtel_money", or "card".
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_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://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://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"}}'
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_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.

A flat UGX 750 fee applies per payout. This is deducted from the merchant's available balance along with the payout amount. If the payout fails, both are fully refunded.

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_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://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" },
  }),
});

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.

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 });
});

Errors

All errors return JSON with an error field.

StatusMeaning
400Bad request — missing field, invalid value, or stale X-Timestamp.
401Unauthorized — missing or invalid Bearer token.
404Resource not found or belongs to a different merchant.
409Idempotency key reused with a different request body.
429Rate limit exceeded. Charges: 30/min. Payouts: 10/min.
# Error response shape
{"error": "insufficient available balance: have 5000, need 50750 (amount 50000 + fee 750)"}