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.
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:
| Status | Meaning |
|---|---|
| pending | Created; the prompt has not reached the handset yet. |
| processing | The prompt is on the customer's phone, awaiting their PIN. |
| completed | Paid. Your balance is credited. Only this means money. |
| failed | Declined, cancelled, or insufficient funds. |
| expired | Not approved within 15 minutes. |
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.
| Code | HTTP | What it means |
|---|---|---|
| unauthorized | 401 | Missing, invalid or revoked API key. |
| account_inactive | 403 | Your merchant account is suspended. |
| insufficient_permissions | 403 | This key lacks that permission. |
| ip_not_allowed | 403 | Calling IP is not on your allowlist. |
| validation_error | 422 | A field failed validation; see fields. |
| amount_too_small / amount_too_large | 422 | Outside the permitted range. |
| insufficient_balance | 402 | Payout exceeds your available balance. |
| payouts_disabled | 403 | Payouts are not enabled on your account. |
| idempotency_key_reused | 409 | Same key, different body. |
| rate_limit_exceeded | 429 | Slow down; see Retry-After. |
| gateway_unavailable | 503 | Upstream 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
| Field | Type | Description |
|---|---|---|
| amount required | integer | Whole TZS, between 1,000 and 5,000,000. |
| customer_phone required | string | Tanzanian mobile number. 0712345678, 712345678 and +255712345678 all work. |
| payment_method optional | string | mobile_money (default) sends a wallet prompt. tanqr creates a bank and wallet QR payment. |
| customer_name optional | string | Shown on receipts. |
| customer_email optional | string | Stored against the payment. |
| reference optional | string | Your own order id. Unique per account — reusing it returns the original payment instead of charging twice. |
| narration optional | string | What the payment is for. |
| callback_url optional | string | Webhook destination for this payment, in addition to your configured endpoints. |
| fee_bearer optional | string | merchant or customer. Overrides your account default. |
| metadata optional | object | 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"
}
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
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_..."
List 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
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.
| Field | Type | Description |
|---|---|---|
| amount required | integer | Whole TZS. |
| recipient_phone required | string | Tanzanian mobile number. |
| recipient_name optional | string | For your records. |
| reference optional | string | Your own id; unique per account. |
| narration optional | string | What it is for. |
| callback_url optional | string | Webhook destination for this payout. |
| metadata optional | object | Echoed back to you. |
Depending on platform policy, a payout may be held for review — it returns with status
pending_approval and completes once approved.
Payout status
Statuses: pending_approval, queued, processing,
completed, failed, cancelled. A failed payout returns
both the amount and the fee to your balance automatically.
Balance
{
"success": true,
"data": {
"currency": "TZS",
"available": 1284500,
"pending": 0,
"lifetime": {
"collected": 8420000,
"fees": 210500,
"paid_out": 3100000,
"withdrawn": 3825000
},
"payouts_enabled": true
}
}
Payment 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.
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.
// 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
| Event | Fires when |
|---|---|
| payment.completed | A customer's payment succeeded and your balance was credited. |
| payment.failed | A payment was declined, cancelled or expired. |
| payout.completed | A payout reached the recipient's wallet. |
| payout.failed | A 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>
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
- Submit your business details under Settings → Verification.
- Once approved, create a
sk_live_key and deploy it. - Point your webhook endpoint at production and confirm you are verifying signatures.
- Run one small real payment end to end before switching customers over.
- 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.