Skip to content
All providers

SasPay

payment·🇧🇫🇧🇯🇨🇩🇨🇮🇨🇲🇬🇭🇬🇳🇰🇪🇲🇱🇲🇼🇳🇪🇳🇬🇷🇼🇸🇳🇹🇬🇹🇿🇺🇬🇿🇲·Sandbox available

SasPay is a single REST API to collect payments (mobile money, card) and pay out beneficiaries by mobile money across West, Central and East Africa, with hosted checkout, payment links, per-country wallets and signed webhooks.

Use with AI agents

After installing the plugin or adding the MCP server, prompt your agent:

“Cancel the pending SasPay checkout session for order #1042 before the customer pays.”
Install the plugin →

Capabilities

CapabilityTypeStatusiMethodExample
cancel_checkout_sessionsynchronousVerifiedPOST
confirm_otpsynchronousAvailablePOST
create_checkout_sessionsynchronousVerifiedPOST
create_paymentsynchronousVerifiedPOST
create_payment_linksynchronousVerifiedPOST
get_balancesynchronousAvailableGET
get_checkout_session_statussynchronousVerifiedGET
retry_paymentsynchronousVerifiedPOST
send_payoutsynchronousAvailablePOST
send_wallet_transfersynchronousAvailablePOST
verify_paymentsynchronousVerifiedGET
verify_payoutsynchronousAvailableGET
webhook_payment_completedwebhookAvailablePOST

Gotchas

cancel_checkout_session

  • ⚠Only a PENDING session can be cancelled: a session already paid, expired or cancelled returns 409 not_pending.

confirm_otp

  • ⚠This is a second step, only for networks that require a customer-side confirmation after the initial push (e.g. Wizall Senegal, Coris Benin). On other networks it returns 400.
  • ⚠A failed confirmation returns a deliberately generic 422 message: never expect the gateway's reason in it.
  • ⚠409 means the transaction cannot be confirmed in its current state (not_pending, no_gateway, no_attempt): check it with verify_payment instead of retrying the OTP.

create_checkout_session

  • ⚠Idempotency-Key is not supported here: calling twice creates two separate sessions (nothing is charged until one is paid).
  • ⚠fee_charge_mode is silently ignored unless the merchant account has ALLOW_CLIENT_OVERRIDE enabled.
  • ⚠The API key needs the PAYIN or BOTH scope, otherwise the call returns 403.
  • ⚠return_url only brings the customer back after a successful payment: after a failed or cancelled one it is not used, the customer sees a Retry button and must close the tab, so don't depend on a redirect to your site for failures.
  • ⚠Amounts are decimal strings ("5000.00"), never floats.

create_payment

  • ⚠Always check checkout_url in the response: for Wave, Orange Money, Djamo and cards you must redirect the customer to it; only when it is empty was a request pushed to the customer's phone.
  • ⚠Networks that need a pre-payment OTP (Orange Money Côte d'Ivoire and Burkina Faso: the customer dials a USSD code first) require the otp field, otherwise 422 prepayment_otp_missing. This is not the post-push SMS OTP, which goes through confirm_otp.
  • ⚠Send an Idempotency-Key (one UUID per payment attempt, reused on retries): without it a network retry creates a second real payment. Reusing a key with a different body, or while the first call is still running, returns 409.
  • ⚠Amounts are decimal strings ("2500.00"), never floats.
  • ⚠To know beforehand whether a network needs the pre-payment OTP (and show the right USSD instructions), read otp_required / otp_instructions from GET /pricing/my-rates/.
  • ⚠Valid network codes per country and the expected customer.phone format are listed on the docs' formats page (/api-reference/reference/formats).
  • ⚠A 400 validation error has another shape than business errors: error is a map of field names to messages ({"field": ["message"]}), not {message, code}.

create_payment_link

  • ⚠No server-side cross-validation: a FIXED link without amount is accepted, so always send amount with amount_type FIXED.
  • ⚠redirect_url is required when show_confirmation_page is false, otherwise 400.
  • ⚠Idempotency-Key is not supported here: calling twice creates two separate links.

get_balance

  • ⚠Balances are per country wallet: two countries with the same currency (e.g. Benin and Senegal, both XOF) still have separate balances.

get_checkout_session_status

  • ⚠Meant for polling when no webhook is configured: it re-checks the real gateway state on each call.
  • ⚠Authenticated calls are limited to 300 requests per minute per account; beyond that the API returns 429, so space out polling.

retry_payment

  • ⚠Retrying reuses the same transaction and id; it never creates a new transaction.
  • ⚠Only incoming payments can be retried: a payout returns 400. A FAILED payment is retryable (that is what this endpoint is for); 409 is for the other terminal states.
  • ⚠Send an Idempotency-Key: without it a network retry relaunches the payment a second time. Reusing a key with a different body, or while the first call is still running, returns 409.
  • ⚠The retry can switch the customer to a hosted page: on the sandbox, a failed mtn_ci push retried with no body came back with a checkout_url (status still FAILED until the customer pays), so check checkout_url on every retry.

send_payout

  • ⚠With an API key, the calling server's IP must be whitelisted for the merchant, otherwise 403 ip_not_whitelisted.
  • ⚠No automatic currency conversion on payouts (unlike payins): the currency must match the beneficiary's country, otherwise 422 currency_country_mismatch.
  • ⚠Send an Idempotency-Key: without it a network retry creates a second disbursement. Reusing a key with a different body, or while the first call is still running, returns 409.
  • ⚠Amounts are decimal strings ("10000.00"), never floats.
  • ⚠Valid method codes per country and the expected recipient.msisdn format are listed on the docs' formats page (/api-reference/reference/formats).
  • ⚠A 400 validation error has a different shape from the business errors: `error` maps each invalid field to a list of messages ({"amount": ["Ce champ est obligatoire."]}) with no error.message or error.code, so read the field map, not just error.message (observed on the sandbox).
  • ⚠Payouts must be enabled for the merchant by SasPay: until then every call returns 403 payout_not_enabled, even with a whitelisted IP (observed on the sandbox).
  • ⚠The minimum payout is 200 XOF: below it, 400 with error.amount ["Le montant minimum est de 200 XOF."] (observed on the sandbox).

send_wallet_transfer

  • ⚠Only one PENDING transfer per merchant at a time: a new call while one awaits admin approval returns 422 transfer_already_pending.
  • ⚠The transfer is COMPLETED immediately only if WALLET_TRANSFER_AUTO_APPROVE is enabled; otherwise it stays PENDING until an admin approves it, and the target wallet is not credited before then.
  • ⚠Send an Idempotency-Key: without it a network retry reserves the amount a second time. Reusing a key with a different body, or while the first call is still running, returns 409.

verify_payment

  • ⚠Only deliver the product or grant access once this returns SUCCESS.
  • ⚠A PENDING payment is re-checked against the gateway on each call, so the status can change between two calls.
  • ⚠Every response is wrapped in the same envelope: read the payload from data, and on failure read error.message and error.code.
  • ⚠Only PENDING, SUCCESS and FAILED occur in practice: CANCELLED exists in the data model but no flow reaches it today, so treat it as a non-successful terminal state without expecting it.
  • ⚠For accounting, never use the requested amount alone: what left the payer and what you receive depend on fee_charge_mode. Read debited_amount and net_amount, or the amounts object (requested, fee, charged, net).

verify_payout

  • ⚠A PENDING payout is re-checked against the gateway on each call, so the status can change between two calls.

webhook_payment_completed

  • ⚠Verify X-Webhook-Signature before anything else: it is the lowercase hex HMAC-SHA256, keyed with your webhook's signing_secret (SASPAY_WEBHOOK_SECRET), of the X-Webhook-Timestamp value, a dot, then the raw body: `${timestamp}.${rawBody}`.
  • ⚠Reject the request when X-Webhook-Timestamp (Unix seconds) is more than 5 minutes from your clock: without this check an intercepted webhook stays replayable forever, since its signature never expires.
  • ⚠Compute the signature over the raw request body, never over a re-serialised parsed payload (key order or number formatting can differ). Express: mount express.raw({ type: 'application/json' }) on this route.
  • ⚠Compare signatures in constant time (crypto.timingSafeEqual, hmac.compare_digest, hash_equals), never with ===.
  • ⚠signing_secret is shown only once, in the creation or rotation response, and never returned by the read endpoints: copy it then. If lost, set a new one from the dashboard (Webhooks → Changer le secret).
  • ⚠Webhooks and subscriptions can only be created, changed or deleted from the dashboard; the API key can only list and read them.
  • ⚠Each event is attempted 5 times (immediately, then +30s, +5min, +30min, +2h) with a 15s timeout per attempt, then marked FAILED for good (only a manual resend from the dashboard retries it). Answer 2xx right away, process in the background, and make processing idempotent on data.id, since a retried event can arrive twice.
  • ⚠For accounting use data.charged (what left the payer) and data.net_amount (what you receive), never data.amount alone: which one includes the fee depends on fee_charge_mode.
  • ⚠Do not depend on the exact data shape of settlement.* events: SasPay states it is not guaranteed yet.
  • ⚠data.msisdn is empty on transactions created before 17 August 2026; country, network and the amounts are always present.

Details

Category
payment
Sandbox
Yes

Spec contributors