docs

Introduction

The Sasi Pay API lets you collect through Tanzanian mobile money prompts or a bank and wallet QR, then send money back out using one integration.

All requests go to:

https://pay.sasidigital.co.tz/v1

Every request and response is JSON. All amounts are whole Tanzanian shillings — mobile money has no cents, so decimals are rounded. All timestamps are ISO 8601 in Africa/Dar_es_Salaam.

Authentication

Authenticate with your secret key in the Authorization header. Keys are created under Developers → API keys in your dashboard.

Authorization: Bearer sk_live_a1b2c3d4e5f6a7b8_xxxxxxxxxxxxxxxxxxxxxxxx

X-API-Key: sk_live_... also works if a bearer token is awkward in your stack.

Secret keys are server-side only. Anyone holding your key can charge customers and move your balance. Never ship one in a mobile app, browser JavaScript or a public repository. For browser checkouts, use the drop-in widget, which uses a payment link code instead of a key.

Check your credentials before writing any real integration:

curl https://pay.sasidigital.co.tz/v1/ping \
  -H "Authorization: Bearer sk_test_..."

Quickstart

Charging a customer takes one call. The customer gets a prompt on their handset and enters their PIN.

// PHP
$ch = curl_init('https://pay.sasidigital.co.tz/v1/payments');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $secretKey,
        'Content-Type: application/json',
        'Idempotency-Key: ' . $orderId,
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'amount'         => 15000,
        'customer_phone' => '0712345678',
        'customer_name'  => 'Asha Mwenda',
        'reference'      => $orderId,
        'narration'      => 'Invoice 1042',
        'callback_url'   => 'https://yourshop.co.tz/webhooks/sasipay',
    ]),
]);

$response = json_decode(curl_exec($ch), true);
curl_close($ch);

if ($response['success']) {
    // Store $response['data']['reference'] against your order,
    // then wait for the webhook.
}

Test mode

New accounts start in test mode with a sk_test_ key. Test keys exercise the full API — validation, idempotency, webhooks, the lot — without touching real money. Live keys unlock once your business is verified.

Payment lifecycle

A collection moves through these states:

StatusMeaning
pendingCreated; the prompt has not reached the handset yet.
processingThe prompt is on the customer's phone, awaiting their PIN.
completedPaid. Your balance is credited. Only this means money.
failedDeclined, cancelled, or insufficient funds.
expiredNot approved within 15 minutes.
Wait for completed before delivering anything. A successful response from POST /v1/payments only means the prompt was sent — the customer has not paid yet. Use the webhook, and treat polling as a fallback.

Idempotency

Networks fail mid-request. Send an Idempotency-Key header on any POST and it is safe to retry: the first response is replayed rather than the action repeating.

Idempotency-Key: order-1042-attempt-1

Keys are scoped to your account and kept for 24 hours. Reusing a key with a different body returns 409 idempotency_key_reused. Your own reference field gives a second layer of protection: two payments with the same reference can never exist.

Errors

Successful responses carry success: true and a data object:

{
  "success": true,
  "data": { ... },
  "request_id": "8f14e45fceea167a5a36dedd4bea2543"
}

Failures carry a stable machine-readable error.code:

{
  "success": false,
  "error": {
    "code": "validation_error",
    "message": "customer_phone must be a valid Tanzanian mobile number",
    "fields": { "customer_phone": "..." }
  },
  "request_id": "8f14e45fceea167a5a36dedd4bea2543"
}

Branch on error.code, never on the message text. Quote request_id to support.

CodeHTTPWhat it means
unauthorized401Missing, invalid or revoked API key.
account_inactive403Your merchant account is suspended.
insufficient_permissions403This key lacks that permission.
ip_not_allowed403Calling IP is not on your allowlist.
validation_error422A field failed validation; see fields.
amount_too_small / amount_too_large422Outside the permitted range.
insufficient_balance402Payout exceeds your available balance.
payouts_disabled403Payouts are not enabled on your account.
idempotency_key_reused409Same key, different body.
rate_limit_exceeded429Slow down; see Retry-After.
gateway_unavailable503Upstream problem. Retry with backoff.

Fees and settlement

Collections are charged at your agreed rate — 2.5% by default. The fee is deducted from the amount before it reaches your balance, so a TZS 10,000 payment at 2.5% credits you TZS 9,750.

If your account is set to fee_bearer: "customer", the fee is added on top instead: the customer is charged the amount plus the fee, and you receive the full amount. You can override this per payment.

Funds land in your available balance (or pending if your account has a settlement hold). Withdraw to your bank or mobile wallet from the dashboard at any time.

Create a payment

POST /v1/payments
FieldTypeDescription
amount requiredinteger Whole TZS, between 1,000 and 5,000,000.
customer_phone requiredstring Tanzanian mobile number. 0712345678, 712345678 and +255712345678 all work.
payment_method optionalstring mobile_money (default) sends a wallet prompt. tanqr creates a bank and wallet QR payment.
customer_name optionalstringShown on receipts.
customer_email optionalstringStored against the payment.
reference optionalstring Your own order id. Unique per account — reusing it returns the original payment instead of charging twice.
narration optionalstringWhat the payment is for.
callback_url optionalstring Webhook destination for this payment, in addition to your configured endpoints.
fee_bearer optionalstring merchant or customer. Overrides your account default.
metadata optionalobject Any JSON you want echoed back on the payment and its webhooks.

Response — 201 Created

{
  "success": true,
  "data": {
    "id": "9f1c2e44-3b8a-4d21-9c77-2e4a1b6d8e30",
    "reference": "SP2608091423KX7M2P",
    "merchant_reference": "INV-1042",
    "status": "processing",
    "payment_method": "mobile_money",
    "amount": 15000,
    "currency": "TZS",
    "fee": 375,
    "fee_bearer": "merchant",
    "charged_amount": 15000,
    "net_amount": 14625,
    "customer": {
      "name": "Asha Mwenda",
      "phone": "255712345678",
      "network": "Vodacom"
    },
    "created_at": "2026-08-09T14:23:11+03:00",
    "expires_at": "2026-08-09T14:38:11+03:00",
    "instructions": "A payment prompt has been sent to 255712345678..."
  },
  "request_id": "8f14e45fceea167a5a36dedd4bea2543"
}
For payment_method: "tanqr", show qr_code_url inside your own checkout so the customer can pay from a supported banking or mobile wallet app without leaving your experience. A payment_token and checkout_url may also be returned as fallbacks. Fetch the QR URL with the same API-key authentication. Completion still arrives through the normal webhook.

Payment status

GET /v1/payments/{reference}

Accepts our reference, the payment id, or your own reference. Open payments are re-checked against the network, at most once every five seconds.

curl https://pay.sasidigital.co.tz/v1/payments/SP2608091423KX7M2P \
  -H "Authorization: Bearer sk_live_..."
Webhooks first, polling second. If you must poll, back off — every four seconds for a couple of minutes, then give up and rely on the webhook. Aggressive polling will hit the rate limit.

List payments

GET /v1/payments

Filters: status (comma-separated), customer_phone, from, to, page, per_page (max 100).

curl "https://pay.sasidigital.co.tz/v1/payments?status=completed&from=2026-08-01&per_page=50" \
  -H "Authorization: Bearer sk_live_..."

Create a payout

POST /v1/payouts

Sends money from your balance to any mobile wallet. Requires payouts to be enabled on your account, and enough available balance to cover the amount plus the payout fee.

FieldTypeDescription
amount requiredintegerWhole TZS.
recipient_phone requiredstringTanzanian mobile number.
recipient_name optionalstringFor your records.
reference optionalstringYour own id; unique per account.
narration optionalstringWhat it is for.
callback_url optionalstringWebhook destination for this payout.
metadata optionalobjectEchoed back to you.
Payouts are irreversible. Once a wallet is credited, the money cannot be pulled back. Validate the recipient number in your own flow before calling this. Your balance is debited when the payout is created and refunded in full if it fails.

Depending on platform policy, a payout may be held for review — it returns with status pending_approval and completes once approved.

Payout status

GET /v1/payouts/{reference}

Statuses: pending_approval, queued, processing, completed, failed, cancelled. A failed payout returns both the amount and the fee to your balance automatically.

Balance

GET /v1/balance
{
  "success": true,
  "data": {
    "currency": "TZS",
    "available": 1284500,
    "pending": 0,
    "lifetime": {
      "collected": 8420000,
      "fees": 210500,
      "paid_out": 3100000,
      "withdrawn": 3825000
    },
    "payouts_enabled": true
  }
}
POST /v1/links

Create a hosted, branded payment page you can share by WhatsApp, SMS or QR code. Useful when there is no checkout to integrate with.

curl https://pay.sasidigital.co.tz/v1/links \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Deposit for order 88",
    "amount": 50000,
    "is_reusable": false,
    "redirect_url": "https://yourshop.co.tz/thanks"
  }'

Omit amount to let the payer choose, optionally bounded by min_amount and max_amount. The response includes a url of the form https://pay.sasidigital.co.tz/pay/abc12345.

GET /v1/links lists them; GET /v1/links/{code} returns one with its collection totals.

Receiving events

When a payment resolves we POST JSON to your endpoint. Configure endpoints under Developers → Webhooks, or pass callback_url per request.

{
  "event": "payment.completed",
  "created_at": "2026-08-09T14:24:03+03:00",
  "data": {
    "reference": "SP2608091423KX7M2P",
    "merchant_reference": "INV-1042",
    "status": "completed",
    "amount": 15000,
    "net_amount": 14625,
    "customer": { "phone": "255712345678" },
    "completed_at": "2026-08-09T14:24:01+03:00"
  }
}

Reply with any 2xx as quickly as you can, then do your work asynchronously. Anything else is retried with exponential backoff, up to 8 times over roughly 12 hours.

Make your handler idempotent. Retries and network hiccups mean the same event can arrive more than once, and events are not ordered. Key your processing on data.reference and ignore anything you have already handled.

Verifying signatures

Every delivery carries X-SasiPay-Signature in the form t=<timestamp>,v1=<hex digest>. Recompute the HMAC-SHA256 over "<timestamp>.<raw body>" using your signing secret and compare with a timing-safe function.

Always verify. Without it, anyone who learns your webhook URL can post fake "payment completed" events and get free goods. Verify on the raw body, before any JSON parsing or framework middleware rewrites it.
// PHP
$payload = file_get_contents('php://input');
$header  = $_SERVER['HTTP_X_SASIPAY_SIGNATURE'] ?? '';

if (!preg_match('/t=(\d+),v1=([a-f0-9]+)/', $header, $m)) {
    http_response_code(400);
    exit;
}

$expected = hash_hmac('sha256', $m[1] . '.' . $payload, $signingSecret);

if (!hash_equals($expected, $m[2]) || abs(time() - (int) $m[1]) > 300) {
    http_response_code(401);
    exit;
}

$event = json_decode($payload, true);
http_response_code(200);   // acknowledge first
// ...then fulfil the order
// Node.js (Express)
const crypto = require('crypto');

app.post('/webhooks/sasipay',
    express.raw({ type: 'application/json' }),   // raw body is essential
    (req, res) => {
        const match = /t=(\d+),v1=([a-f0-9]+)/.exec(req.get('X-SasiPay-Signature') || '');
        if (!match) return res.sendStatus(400);

        const expected = crypto
            .createHmac('sha256', process.env.SASIPAY_WEBHOOK_SECRET)
            .update(match[1] + '.' + req.body.toString())
            .digest('hex');

        if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(match[2]))) {
            return res.sendStatus(401);
        }

        res.sendStatus(200);
        handleEvent(JSON.parse(req.body));
    });

Event reference

EventFires when
payment.completedA customer's payment succeeded and your balance was credited.
payment.failedA payment was declined, cancelled or expired.
payout.completedA payout reached the recipient's wallet.
payout.failedA payout failed; the amount and fee were returned to your balance.

Drop-in widget

For a browser checkout with no backend work, embed the widget. It uses a payment link code, never an API key, so it is safe in client-side code.

<script src="https://pay.sasidigital.co.tz/assets/js/sasipay.js"></script>

<script>
  SasiPay.checkout({
    link:   'abc12345',        // payment link code
    amount: 15000,              // only for open-amount links
    onSuccess: (payment) => {
      window.location = '/thank-you?ref=' + payment.reference;
    },
    onFailure: (payment) => console.log(payment.failure_reason)
  });
</script>

Or bind it declaratively to any element:

<button data-sasipay-link="abc12345" data-sasipay-amount="15000">
  Pay with mobile money
</button>
Always confirm the payment server-side from the webhook before releasing goods. The onSuccess callback runs in the customer's browser and can be faked.

Rate limits

120 requests per minute per account. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining; a 429 carries Retry-After in seconds. Retry with exponential backoff and jitter — never in a tight loop.

Going live

  1. Submit your business details under Settings → Verification.
  2. Once approved, create a sk_live_ key and deploy it.
  3. Point your webhook endpoint at production and confirm you are verifying signatures.
  4. Run one small real payment end to end before switching customers over.
  5. Add your server IPs to the allowlist under Settings → Security.

Stuck on something? Email support@sasidigital.co.tz with the request_id from the response you are asking about.