# Smobilpay S3P API — Integration Brief for AI Agents > Mobile-money payment collection and disbursement API for Cameroon (XAF). > This document is written for AI coding assistants helping a partner build > a client. It is self-contained: a competent AI agent should be able to > produce a working integration from this file plus the OpenAPI YAML in > the same folder. > > **Spec version:** 3.2.0-partner • **OpenAPI:** `s3p_3.2.0_openapi_specs_partner.yml` • **Protocol shape:** send `x-api-version: 3.0.0` (optional but recommended for new integrations). > > **Base URL, partner credentials, and callback URL registration are not > published here — they are issued by Maviance support during partner > onboarding. Use `` as a placeholder in generated code.** --- ## 1. What this API does A façade that authenticates partner traffic, normalizes it, and forwards to the upstream Collection API. Two integration categories cover all use cases: 1. **Collections & Disbursement** — 90% of integrations. - Collection (money IN to you): customer pays you from their mobile wallet. - Disbursement (money OUT from you): you pay a recipient into theirs. 2. **Value-added services (VAS)** — bills, top-ups, vouchers, products, subscriptions. Same core flow, plus a per-customer/per-service lookup. Currency is XAF (Central African CFA franc). Amounts are integers — no decimals. Phone numbers are international format, digits only, no leading `+` (e.g. `237699999999`). --- ## 2. The canonical four-step flow Every Collections & Disbursement transaction follows the same pattern: ``` ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ ┌──────────────┐ │ 1. POST │ │ 2. POST │ │ 3. (customer │ │ 4. callback │ │ /quotestd ├──▶│ /collectstd ├──▶│ approves on ├──▶│ to your URL │ │ │ │ → returns PTN│ │ phone for │ │ (fallback: │ │ │ │ │ │ collection) │ │ /verifytx) │ └──────────────┘ └──────────────┘ └─────────────────┘ └──────────────┘ ``` Disbursements skip step 3 — the recipient does not approve, funds simply land in their wallet on success. --- ## 3. Endpoint name mapping (memorize once) ``` GET /cashout = Collection catalog (money IN to you) GET /cashin = Disbursement catalog (money OUT from you) ``` Both are discovery endpoints — call once at startup, cache the `payItemId` values, never call again unless you refresh the catalog. **Do not try to derive direction from the words `cashin` / `cashout` — treat the names as opaque tokens** and use the mapping above. `payItemId` strings of form `S-1-…` are collection items; `S-2-…` are disbursement items. Don't parse this — just store and re-send. --- ## 4. Headers on every request ```http Authorization: Bearer x-api-version: 3.0.0 Content-Type: application/json ``` - **`x-api-version: 3.0.0` is optional but strongly recommended.** The server never rejects a request for omitting it, but omitting it returns the legacy response shape on some endpoints (e.g. `/v2/ping` reports `2.2.0`, `/v2/service` hides cashout services). New integrations should always send `3.0.0` for the consistent v3 shape. - A correlation header (`x-correlation-id`, alphanumeric + `._-`, ≤128 chars) is optional — if absent, the server generates one. Echo it through your own logs to make support tickets resolvable. --- ## 5. Authentication OAuth 2.0 `client_credentials` is the only supported scheme. ### 5.1. OAuth 2.0 client_credentials Exchange `publicKey` + `secretKey` for a short-lived JWT. ```http POST /oauth/token HTTP/1.1 Authorization: Basic Content-Type: application/x-www-form-urlencoded grant_type=client_credentials ``` ```json { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 3600 } ``` Then on every secured endpoint: ```http Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9... ``` **Caching rules:** - Reuse the bearer until ~60s before `exp`. Tokens are reusable until then — do not mint a fresh one per request. - Tokens are **not** refreshable (no refresh_token). Mint a new one when the cached one is about to expire. - Read `expires_in` off each token response — do not hard-code TTL. - Server validates JWT signature via JWKS (RS256/ES256). HS256 and `alg=none` are rejected. ### 5.2. Server-side credentials only `publicKey` and `secretKey` (and any minted JWT) must stay server-side: - never embed in client apps, mobile binaries, browser code, or VCS - mint tokens on a backend, send only the short-lived bearer downstream --- ## 6. Collection — full happy path ### Step 1. Cache the collection catalog (once at startup) ```http GET /v2/cashout Authorization: Bearer x-api-version: 3.0.0 ``` ```json [ { "payItemId": "S-1-10-CMMTNMOMO-90001-500100-1", "name": "MTN Mobile Money Collection", "amountType": "ANY", "amountLocalCur": null, "localCur": "XAF", "serviceid": "...", "merchant": "..." } ] ``` `amountType: "ANY"` + `amountLocalCur: null` means variable-amount — customer-chosen. `amountType: "FIXED"` means use exactly the listed amount. Cache this list. Refresh daily, not per transaction. ### Step 2. Quote ```http POST /v2/quotestd Authorization: Bearer x-api-version: 3.0.0 Content-Type: application/json { "payItemId": "S-1-10-CMMTNMOMO-90001-500100-1", "amount": 10000 } ``` ```json { "quoteId": "8b2f3c9a-1234-…", "expiresAt": "2026-05-26T14:42:18Z", "payItemId": "S-1-10-CMMTNMOMO-90001-500100-1", "amountLocalCur": 10000, "priceLocalCur": 10000, "localCur": "XAF", "systemCur": "XAF", "promotion": null } ``` **Quote rules:** - `amount` is an **integer**, no decimals, in the item's local currency. - `quoteId` is **single-use** and short-lived (typically minutes). Execute immediately — do not store quotes for later use. - If `amountType` was `FIXED`, the request amount must match the item. ### Step 3. Execute the collection ```http POST /v2/collectstd Authorization: Bearer x-api-version: 3.0.0 Content-Type: application/json { "quoteId": "8b2f3c9a-1234-…", "customerPhonenumber": "237699999999", "customerEmailaddress":"customer@example.com", "trid": "order-00123", "callbackUrl": "" } ``` **Required:** `quoteId`, `customerPhonenumber`, `customerEmailaddress`. **Conditional** (only when the discovery payload sets the matching flag): `customerName`, `customerAddress`, `customerNumber`, `serviceNumber`. **Optional:** `trid`, `callbackUrl`, `tag`, `cdata`. ```json { "ptn": "99999164577717700069821234567890", "timestamp": "2026-05-26T14:42:19Z", "agentBalance": 1250000.0, "receiptNumber": "R-44129", "veriCode": "ABCD1234", "priceLocalCur": 10000.0, "priceSystemCur": 10000.0, "localCur": "XAF", "systemCur": "XAF", "trid": "order-00123", "status": "PENDING", "payItemId": "S-1-10-CMMTNMOMO-90001-500100-1" } ``` **Critical:** **store `ptn` before responding to your end-customer.** This is the canonical transaction reference and the only durable way to look up the transaction if your `trid` is lost or duplicated. Persist it synchronously with your order record; do not return the order receipt to the user until the PTN is written. `status: "PENDING"` means the customer hasn't approved yet (for collection) or settlement hasn't completed (for disbursement). **Do not fulfil on PENDING.** ### Step 4 (default). Receive the callback If you set `callbackUrl` on the request (or your account has one registered), Smobilpay will POST the final status to that URL when the transaction resolves: ```http POST Content-Type: application/json { "ptn": "99999164577717700069821234567890", "trid": "order-00123", "status": "SUCCESS", "errorCode": 0, "timestamp": "2026-05-26T14:42:34Z", "priceLocalCur": 10000.0, "localCur": "XAF" /* additional PaymentStatus fields per the OpenAPI spec */ } ``` **Your handler must:** - respond with a 2xx status within **~5 seconds** — non-2xx is retried with backoff - be idempotent — Smobilpay may resend the same callback if you reply slow or non-2xx - verify the message belongs to your account (your `trid` or your `ptn` from your DB) - treat the callback body as the *source of truth* for the transaction's final state; only fulfil on `status: "SUCCESS"` ### Step 4 (fallback). Poll /verifytx If a callback hasn't arrived within your SLA, or before any retry of a payout, call: ```http GET /v2/verifytx?trid=order-00123 Authorization: Bearer x-api-version: 3.0.0 ``` (Or `?ptn=` — at least one of `ptn` or `trid` is required.) ```json { "ptn": "99999164577717700069821234567890", "trid": "order-00123", "serviceid": "...", "merchant": "...", "timestamp": "2026-05-26T14:42:19Z", "clearingDate": "2026-05-26", "receiptNumber": "R-44129", "veriCode": "ABCD1234", "priceLocalCur": 10000.0, "priceSystemCur": 10000.0, "localCur": "XAF", "systemCur": "XAF", "status": "SUCCESS", "errorCode": 0, "payItemId": "S-1-10-CMMTNMOMO-90001-500100-1" } ``` **Rate limit: at most once per 10 seconds per transaction.** Excessive polling can suspend the account without warning. --- ## 7. Disbursement — diff from Collection Same four steps. Differences: 1. Discovery is `GET /v2/cashin` (not `/cashout`). 2. `payItemId` starts `S-2-…` (not `S-1-…`). 3. No customer-approval step. `POST /collectstd` initiates settlement; the recipient is credited when the provider routes the funds. 4. The customer-facing `customerPhonenumber` is the **recipient's** wallet. **Idempotency on disbursements is critical.** A network error on `POST /collectstd` does *not* mean the payout failed — the upstream may have routed funds already. **Before retrying any disbursement, call `GET /verifytx?trid=` to check the original outcome.** Never pay a recipient twice. ```http POST /v2/collectstd Authorization: Bearer x-api-version: 3.0.0 Content-Type: application/json { "quoteId": "9c1d4b2e-…", "customerPhonenumber": "237680000002", /* recipient wallet */ "customerEmailaddress":"recipient@example.com", "trid": "payout-ref-456", "callbackUrl": "" } ``` --- ## 8. Status state machine The public `status` field on `/collectstd` responses, `/verifytx` responses, and the outbound callback is **always one of four values** — the upstream service collapses its richer internal state machine before anything reaches you. ``` ┌────────────┐ POST │ │ callback / /verifytx resolves to: /collectstd │ ├──── SUCCESS → fulfil order ─────────────▶│ PENDING ├──── ERRORED → read errorCode, surface usrMsg │ ├──── REVERSED → contact support │ │ └────────────┘ ``` **Public enum (the only values that reach partner code):** - `PENDING` — transient. Keep waiting (callback hasn't fired yet). - `SUCCESS` — final. Fulfil the order. - `ERRORED` — final. Read `errorCode`, surface `usrMsg`. - `REVERSED` — final. Reversed during reconciliation; contact support. **Never fulfil on `PENDING`.** > **Why only four values.** The upstream maintains a wider internal state > machine (CREATED, READY, SCHEDULED, DEBITED, CREDITED, INPROCESS, > 2STEPAWAITINGCONFIRMATION, APPROVALTIMEOUT, APPROVALDENIED, > ERROREDREFUNDED, AWAITING_APPROVAL, APPROVED, REJECTED) but collapses > every internal state into one of the four public values above before > emitting on `/collectstd`, `/verifytx`, or the outbound callback. You > never need to handle the internal states. --- ## 9. Idempotency rules - **`trid` is caller-managed.** The API does *not* enforce uniqueness; if you send the same `trid` twice you'll get two transactions. You're responsible for the uniqueness invariant. - **Use a UUID or a deterministic derivation from your own order id.** - **Before any retry**, call `GET /verifytx?trid=` and inspect the result before sending another `POST /collectstd`. - **Network errors are ambiguous.** A 5xx or timeout from `/collectstd` does not mean the transaction did not happen. Always verify before retrying. - **Quote IDs are single-use.** If a quote fails, get a new quote — do not reuse the same `quoteId` on a retry. --- ## 10. Callback handler contract When you register a `callbackUrl` (per-request via the `callbackUrl` field on `/collectstd`, or account-wide during onboarding), Smobilpay will: 1. POST a JSON body containing the final-state PaymentStatus (same shape as `/verifytx`). 2. Expect a 2xx response within ~5 seconds. 3. Retry with backoff on any non-2xx response or timeout. 4. May redeliver after a redeploy or extended downtime — your handler must be **idempotent**. Recommended handler pseudo-code: ``` on POST /your-callback-url with body {ptn, trid, status, ...}: txn = lookup_by_trid(trid) # or by ptn if trid missing if txn is None: return 200 # not ours; acknowledge silently if txn.final_status_already_set: return 200 # idempotent replay set txn.final_status = body.status set txn.error_code = body.errorCode persist txn if body.status == "SUCCESS": enqueue_fulfilment(txn) elif body.status in ("ERRORED", "REVERSED"): enqueue_user_notification(txn) return 200 ``` **Security:** the callback comes from Maviance's infrastructure. Lock your endpoint down with TLS + an out-of-band shared secret or signature (arranged during onboarding) — never accept callbacks from unknown origins. --- ## 11. Error handling ### 11.1. Standard error envelope Every error (4xx / 5xx) returns: ```json { "respCode": 4009, "devMsg": "Access token invalid", "usrMsg": "Authentication error", "link": "" } ``` - **`respCode`** — integer; canonical machine identifier. Match on this. - **`devMsg`** — verbose, for your logs. Do not show to users. - **`usrMsg`** — short, end-customer safe. Surface this in UIs. - **`link`** — support URL placeholder. ### 11.2. Common codes | respCode | Meaning | Retry? | Action | |----------|------------------------------------------|--------|--------| | `4009` | Access token invalid / expired | Yes | Mint a new OAuth token | | `40011` | Multiple filter params on /historystd | No | Use exactly one filter | | `40201` | Insufficient agent balance | No | Top up; alert ops | | `41004` | Provider gateway unavailable | Yes | Backoff + retry | | `4204` | Invalid wallet number format | No | Validate phone format | | `703107` | Wallet has insufficient funds (collection)| No | Customer top-up needed | | `703201` | Approval prompt timed out (collection) | No | Offer a resend | | `703202` | Customer declined (collection) | No | Notify customer | ### 11.3. Errors on /verifytx The `errorCode` integer on the PaymentStatus payload is the **service provider's** error code, not the same numbering as `respCode`. `0` means "no error" — proceed by checking `status`. ### 11.4. HTTP status codes - `200`/`201` — success - `4xx` — your request is wrong, see `respCode`; do not retry without fixing - `5xx` / transport errors — possibly transient, retry with backoff; **always verify via `/verifytx?trid=…` before sending a second `/collectstd` with the same `trid`** --- ## 12. Value-added services (VAS) Same canonical flow plus a discovery and (sometimes) a verification step. ### 12.1. Endpoints | Endpoint | Type | Lookup | Notes | |-----------------------|-------------------------------|---------------|-------| | `GET /v2/bill` | SEARCHABLE_BILL / NON_SEARCHABLE_BILL | Per customer | `payItemId` expires; complete quote→execute promptly | | `GET /v2/topup` | TOPUP | At startup | Static catalog | | `GET /v2/voucher` | VOUCHER | At startup | Returns a redeemable `pin` on collection | | `GET /v2/product` | PRODUCT | At startup | Static catalog | | `GET /v2/subscription`| SUBSCRIPTION | Per customer | Tied to a customer account | | `GET /v2/verify` | (pre-quote verifier) | When `isVerifiable=true` on discovery | | ### 12.2. VAS flow ``` discovery → (if isVerifiable: /v2/verify) → /v2/quotestd → /v2/collectstd → callback (/v2/verifytx fallback) ``` For **bills**, the discovery payload often contains the exact amount due — treat the bill's `payItemId` as time-sensitive (expires faster than top-ups/products). For **vouchers**, the successful `/collectstd` response includes a `pin` field — display this to the customer immediately; this is the redeemable code. --- ## 13. Auxiliary endpoints | Endpoint | Purpose | |-----------------------|---------| | `GET /v2/account` | Agent account balance. Included in every collection response (`agentBalance`) — only call on demand. | | `GET /v2/merchant` | Merchant master data. Cache daily. | | `GET /v2/service` | Service master data. Cache daily. | | `GET /v2/historystd` | Paginated transaction history. One filter param only — error `40011` if multiple. | | `GET /v2/ping` | Authenticated health check. Returns 200 with a valid bearer. | | `GET /ping` | Anonymous root-level ping (no auth). | | `GET /health` | Anonymous health endpoint. | --- ## 14. Pacing and rate limits - **`/verifytx`**: at most **1 call per 10 seconds per transaction**. Excessive polling triggers account suspension. - **Master data** (`/merchant`, `/service`, `/cashin`, `/cashout`, `/topup`, `/product`, `/voucher`): cache locally and refresh **daily**. - **`/account`**: do not poll. The agent balance is returned on every collection response under `agentBalance`. --- ## 15. Common AI-agent mistakes to avoid 1. **Generating a "currency converter" that divides amount by 100** — XAF has no minor units. `10000` means ten thousand XAF, not 100.00 XAF. 2. **Sending the phone number with a leading `+`** — wrong. Digits only: `237699999999`. 3. **Treating `customerEmailaddress` as optional** — it is required by `POST /v2/collectstd`. Don't skip it. 4. **Omitting `x-api-version: 3.0.0`** — it is optional and never rejected, but without it some endpoints return the legacy response shape (e.g. `/v2/ping` reports `2.2.0`, `/v2/service` hides cashout services). 5. **Fulfilling on `PENDING`** — never. Wait for `SUCCESS`. 6. **Polling `/verifytx` in a tight loop** — minimum 10 seconds between calls per transaction. The right pattern is "wait for callback, fall back to polling only after SLA." 7. **Retrying `/collectstd` on network error without checking `/verifytx`** — could pay a recipient twice. 8. **Reusing a `quoteId`** — single-use. On retry, get a new quote. 9. **Storing `secretKey` in mobile binaries / browser code** — never. Mint tokens server-side only. 10. **Trying to "decode" `/cashin` vs `/cashout` from the word** — use the explicit mapping in §3. Collection = `/cashout`, Disbursement = `/cashin`. 11. **Hard-coding `expires_in`** — read it off each token response. 12. **Re-minting a bearer on every request** — cache it; reuse until ~60s before `exp`. --- ## 16. Reference: minimal pseudo-code happy path ```pseudo # One-time catalog = GET /v2/cashout # cache locally; refresh daily token = mint_oauth_token(publicKey, secretKey) # cache until exp - 60s # Per transaction (collection of 10,000 XAF from a customer) quote = POST /v2/quotestd { payItemId: catalog[0].payItemId, amount: 10000 } response = POST /v2/collectstd { quoteId: quote.quoteId, customerPhonenumber: "237699999999", customerEmailaddress: "customer@example.com", trid: my_order_id, callbackUrl: "https://my.app/smobilpay-callback" } # Persist before responding to the customer store_to_db({ order_id: my_order_id, ptn: response.ptn, status: "PENDING" }) # Resolution arrives via callback (default) or polling (fallback) on_callback(body): update_db_by_trid(body.trid, status=body.status, errorCode=body.errorCode) if body.status == "SUCCESS": fulfil(body.trid) # Fallback: if no callback within SLA status = GET /v2/verifytx?trid=my_order_id # max 1× per 10s per tx if status.status == "SUCCESS": fulfil(my_order_id) ``` --- ## 17. Onboarding — what comes from where **Maviance issues** during partner onboarding (not in public docs): - Base URL (staging and production) - `publicKey` and `secretKey` - Callback URL registration (or accept per-request `callbackUrl`) - Out-of-band callback signing secret (recommended) **The integrator provides:** - An HTTPS callback endpoint that responds 2xx within ~5s - Persistent storage for `ptn` ↔ `trid` ↔ order state - A retry/backoff layer for `/verifytx` (≥10s spacing per tx) - Server-side credential storage — never in mobile binaries or VCS --- ## 18. Specifications and authoritative sources The **partner OpenAPI YAML is the source-of-truth contract** for everything in this brief: - **`s3p_3.2.0_openapi_specs_partner.yml`** — the curated, partner-facing spec. Documents only the endpoints a partner needs to integrate. No internal/upstream implementation references, no operator-only endpoints. OAuth 2.0 only. If this brief diverges from that YAML, the YAML wins. This brief is a friendlier orientation; generate clients from the YAML. The HTML developer guide (`s3p_3.2.0_developer_page.html`) embeds all three machine-readable resources for agents that landed on the page first: - Compact JSON summary (endpoints, errors, mapping): `JSON.parse(document.querySelector('#api-summary').textContent)` - Full markdown brief (this file, verbatim): `document.querySelector('#llms-txt').textContent` - Full partner OpenAPI YAML: `document.querySelector('#openapi-spec').textContent` The canonical standalone copies are at `apidocs/s3p_3.2.0_llms.txt` and `apidocs/s3p_3.2.0_openapi_specs_partner.yml` — those are the authoritative sources if the inline copies ever drift. For questions not covered here, ask the partner to open a ticket with Maviance support and include their `x-correlation-id` from the failing request.