MoniWave

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

  1. Your server creates a checkout session with your secret key. The amount is fixed here, by you.
  2. You redirect the customer to the returned url.
  3. The customer pays on our page. Their browser calls the token endpoints below — no key, no sign-in.
  4. 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.completed

The 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-sessions

Requires Authorization and Idempotency-Key.

Request

FieldTypeRequiredNotes
amountintegeryesMinor units. XAF has no minor unit, so 5000 = 5 000 francs. Must be positive.
currencystringyesISO 4217. XAF at launch.
descriptionstringyesShown to the customer on the page, and in your dashboard. Write it for them.
externalIdstringnoYour own reference (order id, invoice number). Carried onto the resulting collection.
returnUrlstringnoAbsolute 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

CodeHTTP
invalid_amount400Not a positive integer in minor units.
invalid_currency400Unsupported currency.
description_required400Missing or blank.
invalid_return_url400Not 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
}
FieldNotes
statusopen, paid, or expired. An open session past its deadline reads as expired.
merchantDisplayNameYour workspace name, so the customer recognises who they are paying.
transactionStatusnull until a payment is attempted, then the live collection status.
failureReasonCodeWhy 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.

CodeHTTP
invalid_msisdn400Wrong format — digits only, no +, 8–15 characters.
checkout_already_paid400This session has been paid. One session yields at most one successful payment.
checkout_expired400Past 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.