Authentication
One base URL serves both sandbox and production. The X-Target-Environment header picks which. Every call (except /auth itself) needs a short-lived Bearer token plus three identifying headers.
Authorization | Bearer <access_token> |
X-CLIENT-ID | Your app's client ID |
X-Target-Environment | sandbox | production |
X-COUNTRY | UG |
Tokens are valid for 1 hour. Requesting a new one before expiry just hands back the same token with its remaining lifetime, so there's no need to track expiry yourself.
/auth
// Request body (or send client_id as the X-CLIENT-ID header instead) { "client_id": "{your_client_id}", "client_secret": "{your_client_secret}" } // Response (200 OK) { "status": "success", "data": { "access_token": "3f9a...c1", "token_type": "Bearer", "expires_in": 3600 } }
Integrate in Under 10 Minutes
Get your client ID and secret from your Merchant dashboard, request a token, then make your first sandbox call.
Create a Merchant account at no cost. Sandbox access is instant.
Find them under Settings → API Access, alongside the full interactive docs and a ready-made Postman collection for your app.
POST to /auth with your client credentials to get a Bearer token you can reuse for up to an hour.
POST to /requestToPay with a test phone number to trigger a sandbox collection prompt.
// Trigger a USSD PIN prompt on the customer's phone curl -X POST https://www.sandbox.shashura.com/api/v1/requestToPay \ -H "Authorization: Bearer {token}" \ -H "X-CLIENT-ID: {client_id}" \ -H "X-Target-Environment: sandbox" \ -H "X-COUNTRY: UG" \ -H "Content-Type: application/json" \ -d '{ "msisdn": "256770000000", "amount": 500, "reference": "ORDER-101" }' // Response (202 Accepted) { "status": "success", "data": { "tid": "SCI177175711", "message": "Payment request successfully pushed. Awaiting customer PIN entry." } }
// Trigger a USSD PIN prompt on the customer's phone $ch = curl_init('https://www.sandbox.shashura.com/api/v1/requestToPay'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $token, 'X-CLIENT-ID: ' . $clientId, 'X-Target-Environment: sandbox', 'X-COUNTRY: UG', 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'msisdn' => '256770000000', 'amount' => 500, 'reference' => 'ORDER-101', ]), ]); $response = json_decode(curl_exec($ch));
// Send money to a mobile wallet (protected by an atomic lien on your balance) curl -X POST https://www.sandbox.shashura.com/api/v1/disburse \ -H "Authorization: Bearer {token}" \ -H "X-CLIENT-ID: {client_id}" \ -H "X-Target-Environment: sandbox" \ -H "X-COUNTRY: UG" \ -H "Content-Type: application/json" \ -d '{ "msisdn": "256701222333", "amount": 5000, "reference": "PAY-9902", "remarks": "Commission" }' // Response (200 OK) { "status": "success", "code": "TRANSACTION_COMPLETED", "data": { "tid": "WTH250825120501", "amount": 5000, "charge": 500, "status": "PENDING", "message": "Payout initiated. Waiting for MTN confirmation." } }
// Send money to a mobile wallet (protected by an atomic lien on your balance) $ch = curl_init('https://www.sandbox.shashura.com/api/v1/disburse'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $token, 'X-CLIENT-ID: ' . $clientId, 'X-Target-Environment: sandbox', 'X-COUNTRY: UG', 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'msisdn' => '256701222333', 'amount' => 5000, 'reference' => 'PAY-9902', 'remarks' => 'Commission', ]), ]); $response = json_decode(curl_exec($ch)); // Airtel settles instantly (status "TS"); MTN stays PENDING until the // callback fires. A re-used reference returns 409 DUPLICATE_REFERENCE.
// Reverse a settled collection back onto your own wallet. Only the // principal you received is reversed — the fee is kept. Omit amount // (or pass 0) to refund whatever remains; pass a smaller amount for a partial refund. curl -X POST https://www.sandbox.shashura.com/api/v1/refund \ -H "Authorization: Bearer {token}" \ -H "X-CLIENT-ID: {client_id}" \ -H "X-Target-Environment: sandbox" \ -H "X-COUNTRY: UG" \ -H "Content-Type: application/json" \ -d '{ "tid": "ORDER-101", "amount": 0, "reason": "Customer requested a refund", "send_to_customer": false }' // Response (200 OK) { "status": "success", "code": "REFUNDED", "data": { "tid": "ORDER-101", "amount_refunded": 487.5, "refunded_total": 487.5, "refundable_total": 487.5, "fully_refunded": true, "payout": null } }
// Reverse a settled collection — amount omitted (or 0) refunds whatever remains $ch = curl_init('https://www.sandbox.shashura.com/api/v1/refund'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $token, 'X-CLIENT-ID: ' . $clientId, 'X-Target-Environment: sandbox', 'X-COUNTRY: UG', 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'tid' => 'ORDER-101', 'amount' => 0, 'reason' => 'Customer requested a refund', 'send_to_customer' => false, ]), ]); $response = json_decode(curl_exec($ch)); // Only your principal is reversed — our processing fee is never refunded, // on a full or partial refund. Set send_to_customer=true to also push a // real payout of the refunded amount back to the original msisdn.
// Look up by your own reference, or our tid, same identifier slot curl -X GET https://www.sandbox.shashura.com/api/v1/enquiry/ORDER-101 \ -H "Authorization: Bearer {token}" \ -H "X-CLIENT-ID: {client_id}" \ -H "X-Target-Environment: sandbox" \ -H "X-COUNTRY: UG" // Response (200 OK) { "status": "success", "data": { "tid": "SCI177175711", "external_ref": "ORDER-101", "msisdn": "256770000000", "amount": 500, "state": "TS", "network_ref": "881290312", "created_at": "2026-08-25 12:05:01" } } // If state is "TF", data also carries a "failure" object — omitted entirely // on PENDING/TS so existing integrations are unaffected: { "failure": { "code": "INSUFFICIENT_FUNDS", "message": "Not enough balance on this mobile money account." } }
// state: PENDING · TS (success) · TF (failed) — on TF, data.failure.code is // one of: INSUFFICIENT_FUNDS, WRONG_PIN, CANCELLED_BY_USER, TIMED_OUT, // LIMIT_EXCEEDED, ACCOUNT_ERROR, PROVIDER_ERROR, UNKNOWN $ch = curl_init('https://www.sandbox.shashura.com/api/v1/enquiry/ORDER-101'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $token, 'X-CLIENT-ID: ' . $clientId, 'X-Target-Environment: sandbox', 'X-COUNTRY: UG', ], ]); $response = json_decode(curl_exec($ch));
Real-Time Payment Callbacks
Instead of polling, set a callback URL on your app (Settings → Apps) and we'll POST the result there the moment a transaction reaches a final state, usually within seconds. Each app has its own callback URL and signing secret.
transaction.finalized
One event covers both collections and payouts. Check the type (COLLECTION / DISBURSEMENT) and status (SUCCESSFUL / FAILED) fields in the payload.
{
"event": "transaction.finalized",
"environment": "sandbox",
"tid": "SCI177175711",
"external_ref": "ORDER-101",
"type": "COLLECTION",
"status": "SUCCESSFUL",
"amount": 500,
"charge": 12.5,
"net_amount": 487.5,
"msisdn": "256770000000",
"network": "MTN",
"timestamp": "2026-08-25T12:05:03+03:00"
}
// If status is "FAILED", the payload also carries a "failure" object:
{
"failure": {
"code": "INSUFFICIENT_FUNDS",
"message": "Not enough balance on this mobile money account."
}
}
$raw = file_get_contents('php://input'); $sig = hash_hmac('sha256', $raw, $yourWebhookSecret); if (!hash_equals($sig, $_SERVER['HTTP_X_SHASHURA_SIGNATURE'] ?? '')) { http_response_code(401); exit; // reject forgeries } $event = json_decode($raw); http_response_code(200); // acknowledge with any 2xx
X-Shashura-Signature header: an HMAC-SHA256 of the raw body keyed with your app's webhook secret (Settings → Apps). Always verify it before trusting the payload.
Failure codes (data.failure.code / failure.code)
INSUFFICIENT_FUNDS
Not enough balance on the customer's mobile money account.
WRONG_PIN
The customer entered an incorrect PIN.
CANCELLED_BY_USER
The customer cancelled or rejected the payment prompt.
TIMED_OUT
The customer did not respond to the prompt in time.
LIMIT_EXCEEDED
This would exceed the account's mobile money transaction limit.
ACCOUNT_ERROR
The mobile money account could not be reached (not found / not allowed).
PROVIDER_ERROR
The mobile network operator reported an internal error — safe to retry.
UNKNOWN
A decline reason the operator sent that we haven't classified yet.
Error Glossary
Every response carries a status field (and a code on errors). Treat that as the source of truth alongside the HTTP status.
Missing or malformed msisdn, amount, or reference.
Bearer token missing, invalid, or expired. Re-authenticate via /auth.
client_secret didn't match, or no app exists for that client ID + environment.
Available balance (actual minus pending-payout liens) is too low for this payout.
Caller IP is not in this app's allow-list.
No transaction matches the identifier you queried.
Wrong HTTP verb for this endpoint (e.g. GET on a POST-only route).
This reference was already used for a transaction on this app.
Unexpected error on our end, logged and investigated automatically.
Ready to start building?
Sign up for a free Merchant account to get sandbox credentials, the full interactive API reference, and a ready-made Postman collection.
Get API Keys