Hosted checkout
Hosted checkout is a page we host and you send a customer to. They pick an operator, enter their number, and approve on their phone. You never handle the MSISDN, and no key ever reaches a browser.
Use it when you do not have the customer's number — a payment link in a chat message, a "Pay now" button on a site, an invoice email. If you already know the number, call POST /v1/collections directly and skip this entirely.
How it works
- Your server creates a checkout session with your secret key. The amount is fixed here, by you.
- You redirect the customer to the returned
url. - The customer pays on our page. Their browser calls the token endpoints below — no key, no sign-in.
- You learn the outcome the same way as any other payment: the webhook for the resulting collection, or by polling it. The checkout page is not your notification channel.
sequenceDiagram
participant S as Your server
participant M as MoniWave API
participant C as Customer's browser
S->>M: POST /v1/checkout-sessions (sk_…)
M-->>S: { id, url }
S-->>C: redirect to url
C->>M: GET /v1/checkout/{token}
C->>M: POST /v1/checkout/{token}/pay { msisdn }
M-->>C: 202 accepted
C->>M: GET /v1/checkout/{token} (poll)
M-->>S: webhook: collection.completedThe session's payment is a collection — same lifecycle, same statuses, same webhooks, same ledger entry, same col_… id (transaction-lifecycle.md). Checkout is a way to start one, not a second kind of payment.
Create a checkout session
POST /v1/checkout-sessionsRequires Authorization and Idempotency-Key.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
amount | integer | yes | Minor units. XAF has no minor unit, so 5000 = 5 000 francs. Must be positive. |
currency | string | yes | ISO 4217. XAF at launch. |
description | string | yes | Shown to the customer on the page, and in your dashboard. Write it for them. |
externalId | string | no | Your own reference (order id, invoice number). Carried onto the resulting collection. |
returnUrl | string | no | Absolute http(s) URL. The customer gets a button back to it once the payment settles. Absent = they stay on the result page. |
curl -X POST https://api-dev.moni-wave.com/v1/checkout-sessions \
-H "Authorization: Bearer sk_test_…" \
-H "Idempotency-Key: 3f9c1e2a-7b4d-4a1f-9c8e-2d6b5a0f3e11" \
-H "Content-Type: application/json" \
-d '{
"amount": 5000,
"currency": "XAF",
"description": "Order #1042",
"externalId": "order-1042",
"returnUrl": "https://shop.example.cm/orders/1042"
}'Response — 201 Created
{
"id": "chk_019fff54dbf37614b3420ade5ab24ae1",
"url": "https://pay.moni-wave.com/c/9f2c…",
"amount": 5000,
"currency": "XAF",
"description": "Order #1042",
"externalId": "order-1042",
"expiresAt": "2026-08-16T08:12:59.243Z"
}Session ids are prefixed chk_.
The url contains a one-time token and is returned exactly once — we store only its hash and cannot show it to you again. Redirect to it or persist it yourself; losing it means creating a new session.
Sessions live 24 hours. Long enough to be opened later from a chat message, short enough that a stale price cannot be paid.
Errors
| Code | HTTP | |
|---|---|---|
invalid_amount | 400 | Not a positive integer in minor units. |
invalid_currency | 400 | Unsupported currency. |
description_required | 400 | Missing or blank. |
invalid_return_url | 400 | Not an absolute http(s) URL. Rejected at creation rather than stranding the customer at the end. |
Plus the shared authentication and idempotency codes.
The token endpoints
The three routes below are called by the customer's browser and are unauthenticated — the token is the entire authorisation. They exist so our checkout page can work; you do not normally call them, and they are documented here so the surface is not secret.
The token authorises exactly three things on exactly one session: read it, pay it, poll it. It cannot create a charge, name an amount, or reach anything else you own.
Read a session
GET /v1/checkout/{token}{
"status": "open",
"merchantDisplayName": "Example Shop",
"amountMinorUnits": 5000,
"currency": "XAF",
"description": "Order #1042",
"returnUrl": "https://shop.example.cm/orders/1042",
"transactionStatus": null,
"failureReasonCode": null
}| Field | Notes |
|---|---|
status | open, paid, or expired. An open session past its deadline reads as expired. |
merchantDisplayName | Your workspace name, so the customer recognises who they are paying. |
transactionStatus | null until a payment is attempted, then the live collection status. |
failureReasonCode | Why the attempt failed (reason codes); null otherwise. |
This response carries only what the page must render. Never your merchant id, your keys, your contact details, or any other transaction.
Unknown, malformed, and expired-and-purged tokens all return the same 404 — the endpoint is guessable by anyone on the internet, so it must not confirm that a session exists.
Pay a session
POST /v1/checkout/{token}/pay{ "msisdn": "237690000001" }Returns 202 Accepted with { "status": "accepted" }. Accepted, not paid — the customer still has to approve on their phone.
The amount always comes from the session, never from this request. There is no Idempotency-Key: a browser has no secret and no stable identity, so the session is the idempotency scope. While an attempt is still live (accepted or submitted) or already completed, this returns that attempt instead of starting a second one. Only a dead attempt (failed, expired) reopens the session — which is exactly what lets a customer retry after a decline or a mistyped number.
| Code | HTTP | |
|---|---|---|
invalid_msisdn | 400 | Wrong format — digits only, no +, 8–15 characters. |
checkout_already_paid | 400 | This session has been paid. One session yields at most one successful payment. |
checkout_expired | 400 | Past its deadline. Create a new session. |
Poll a session
Polling is GET /v1/checkout/{token} again — our page calls it every 2.5 seconds while an attempt is in flight and stops at a terminal status.
This is the page's channel, not yours. Your integration learns the outcome from the collection's webhook or by polling GET /v1/collections/{id}, which works whether or not the customer kept the tab open. Never treat "the customer reached the success page" as proof of payment — they may close the tab a second before it settles.
Cross-origin access
The token endpoints are the only part of this API a browser may call, and they answer CORS for the checkout origin alone (PublicApi:CheckoutOrigins; http://localhost:6020 in local development).
Every other /v1 endpoint deliberately sends no CORS headers. They authenticate with a secret key, which must never be in a browser — making them reachable cross-origin would invite exactly the mistake authentication.md forbids. Server-to-server callers are unaffected: they send no Origin header, so CORS never applies to them.
Security notes
- The page is platform-branded and served from its own origin (
pay.moni-wave.com), framing denied. Merchant branding is a later, additive change. - The token travels in a URL people screenshot and forward. That is why a leaked one risks paying a bill someone already owed rather than minting a new charge — the worst case is a stranger paying your customer's invoice.
- These routes are anonymous, so rate limiting is a launch requirement (WP 4.5), not an optimisation.
pk_test_/pk_live_publishable keys are not used here. See authentication.md.
Testing
The page is a normal collection underneath, so the magic MSISDNs force every outcome deterministically: enter 237690000001 for an immediate success, 237690000003 for a decline, and so on. The result screen, the retry path, and the expiry message are all reachable in the sandbox without touching a real operator.