Hosted Checkout
Hosted Checkout lets your backend create a short-lived ZoPay payment session, then redirect the customer to a secure ZoPay-hosted page at /pay/:checkoutSessionId.
Important Implementation Rule
Your browser frontend must not call POST /api/v1/checkout/sessions directly. Checkout session creation belongs on the merchant backend only because it uses merchant API credentials, signed headers, and allowlist validation against the real calling server/domain/IP.
- Creating checkout sessions must happen from the merchant backend.
- The merchant backend server/domain/IP must be approved in ZoPay.
- Sending
x-zo-originalone is not enough if the real request comes from the wrong place.
When to Use Hosted Checkout
Use Hosted Checkout for ecommerce and customer-facing payment flows where ZoPay should own the payment page. Direct collection APIs are still available for approved server-to-server integrations, but Hosted Checkout is the recommended browser checkout flow.
Flow
1Merchant backend
2 -> POST /api/v1/checkout/sessions with API key + HMAC signature
3 -> receives checkoutSession.checkoutUrl
4Merchant website
5 -> redirects customer to https://checkout.zopay.co/pay/:checkoutSessionId
6ZoPay hosted page
7 -> loads session, collects payer details, submits payment, polls status
8 -> redirects to successUrl/cancelUrl when backend returns redirectUrl- Your backend creates a checkout session with your API key and HMAC signature.
- ZoPay returns a
checkoutUrl. - Your site redirects the customer to that URL.
- The customer selects MTN MoMo or Orange Money and enters payment details.
- ZoPay starts the collection and polls/verifies status.
- ZoPay redirects to your success or cancel URL when available.
Hosted Checkout URL
Backend returns a hosted checkoutUrl in the format {CHECKOUT_BASE_URL}/pay/{checkoutSessionId}.
1https://checkout.zopay.co/pay/<sessionId>
2http://localhost:3001/pay/<sessionId>The public hosted-page route in this frontend is therefore /pay/:checkoutSessionId.
Routes Summary
POST /api/v1/checkout/sessionsGET /api/v1/checkout/sessions/:idGET /public/v1/checkout/sessions/:idPOST /public/v1/checkout/sessions/:id/payGET /public/v1/checkout/sessions/:id/status
Backend Migration
Hosted Checkout uses the checkout_sessions table frommigrations/013_add_checkout_sessions.sql. Local development has already been updated with npm run db:push on April 23, 2026. For any other environment, run the migration or DB push before testing if that environment does not already have the table.
Create a Session
This route is server-to-server only. Never call it from the customer browser and never expose your merchant secret key.
1POST /api/v1/checkout/sessions
2Content-Type: application/json
3x-zo-key: <merchant_api_key>
4x-zo-timestamp: <unix_ms>
5x-zo-nonce: <unique_nonce>
6x-zo-origin: <merchant_origin>
7x-zo-signature: <hmac_signature>
8x-zo-version: 1.01{
2 "amount": "5000",
3 "currency": "XAF",
4 "description": "Order #1001",
5 "gateways": ["MTN_MOMO", "ORANGE_MONEY"],
6 "expiresInMinutes": 30,
7 "successUrl": "https://merchant.example.com/pay/success",
8 "cancelUrl": "https://merchant.example.com/pay/cancel",
9 "webhookUrl": "https://merchant.example.com/webhooks/zopay",
10 "metadata": {
11 "orderId": "ORDER-1001",
12 "cartId": "CART-22"
13 },
14 "customer": {
15 "name": "Customer Name",
16 "email": "customer@example.com",
17 "phone": "2376XXXXXXXX"
18 }
19}Request Notes
amountis the product or order amount before fee calculation.currencymust be a 3-letter code such asXAF.gatewaysis optional and defaults to MTN MoMo and Orange Money.expiresInMinutesis optional and defaults to30.successUrl,cancelUrl, andwebhookUrlmust be valid HTTP or HTTPS URLs when provided.metadatais returned in checkout webhooks and status responses.customeris optional and can prefill hosted checkout fields.
Security Notes
- This route uses merchant API key lookup and environment detection from the API key.
- Timestamp and nonce replay protection are enforced.
- HMAC signature validation is required.
- IP/domain allowlist validation is required.
- If the merchant uses the wrong real server, domain, or IP, session creation can fail with
403 Access Forbidden.
Success Response
1{
2 "checkoutSession": {
3 "id": "checkout-session-uuid",
4 "checkoutUrl": "https://checkout.zopay.co/pay/checkout-session-uuid",
5 "amount": "5000.00",
6 "currency": "XAF",
7 "description": "Order #1001",
8 "gateways": ["MTN_MOMO", "ORANGE_MONEY"],
9 "environment": "production",
10 "status": "PENDING",
11 "payable": true,
12 "successUrl": "https://merchant.example.com/pay/success",
13 "cancelUrl": "https://merchant.example.com/pay/cancel",
14 "metadata": {
15 "orderId": "ORDER-1001"
16 },
17 "selectedGateway": null,
18 "payerName": "Customer Name",
19 "payerEmail": "customer@example.com",
20 "payerMsisdn": "2376XXXXXXXX",
21 "transactionId": null,
22 "gatewayReference": null,
23 "failureReason": null,
24 "redirectUrl": null,
25 "expiresAt": "2026-04-23T10:30:00.000Z",
26 "createdAt": "2026-04-23T10:00:00.000Z",
27 "updatedAt": "2026-04-23T10:00:00.000Z"
28 }
29}Your backend should return checkoutSession.checkoutUrl to your website, and your website should redirect the customer there. Your frontend should never expose the merchant secret key.
Merchant Backend Can Fetch the Session Again
Merchant backends can also fetch the latest mapped status for a session using: GET /api/v1/checkout/sessions/:id.
- This route is protected by the same API gateway and allowlist rules.
- It returns only the merchant's own session.
- Document it as a merchant-backend helper route, not as a public customer route.
Public Checkout Page Endpoints
The hosted page uses these public endpoints. They do not require bearer auth.
GET /public/v1/checkout/sessions/:idloads the session.POST /public/v1/checkout/sessions/:id/paystarts payment.GET /public/v1/checkout/sessions/:id/statuspolls final status.
Load Session Response
1{
2 "checkoutSession": {
3 "id": "checkout-session-uuid",
4 "merchantName": "Merchant Business Name",
5 "merchantLogoUrl": "https://signed-logo-url",
6 "checkoutUrl": "https://checkout.zopay.co/pay/checkout-session-uuid",
7 "amount": "5000.00",
8 "currency": "XAF",
9 "description": "Order #1001",
10 "gateways": ["MTN_MOMO", "ORANGE_MONEY"],
11 "environment": "production",
12 "status": "PENDING",
13 "payable": true,
14 "metadata": {
15 "orderId": "ORDER-1001"
16 },
17 "selectedGateway": null,
18 "payerName": null,
19 "payerEmail": null,
20 "payerMsisdn": null,
21 "payerComment": null,
22 "transactionId": null,
23 "gatewayReference": null,
24 "failureReason": null,
25 "redirectUrl": null,
26 "expiresAt": "2026-04-23T10:30:00.000Z"
27 }
28}Hosted Page Display Rules
- Show
merchantLogoUrlat the top when present. - When the logo is missing, fall back to merchant name or default ZoPay branding.
- If
payableis false or status is notPENDING, disable the form and show the matching state. - Treat
merchantLogoUrlas temporary because it is a signed URL.
Pay Request
1{
2 "gateway": "MTN_MOMO",
3 "payer": {
4 "msisdn": "2376XXXXXXXX",
5 "name": "Customer Name",
6 "email": "customer@example.com"
7 },
8 "comment": "Please deliver after 5 PM",
9 "idempotencyKey": "optional-client-generated-key"
10}Pay Response
1{
2 "checkoutSession": {
3 "id": "checkout-session-uuid",
4 "status": "PROCESSING",
5 "selectedGateway": "MTN_MOMO",
6 "payerName": "Customer Name",
7 "payerEmail": "customer@example.com",
8 "payerMsisdn": "2376XXXXXXXX",
9 "payerComment": "Please deliver after 5 PM",
10 "transactionId": "transaction-uuid",
11 "gatewayReference": "gateway-ref",
12 "redirectUrl": null
13 },
14 "quote": {
15 "quoteId": "quote-uuid",
16 "amount": "5000.00",
17 "totalAmount": "5150.00",
18 "currency": "XAF",
19 "gatewayFee": "100.00",
20 "platformFee": "50.00",
21 "netToMerchant": "5000.00",
22 "expiresAt": "2026-04-23T10:15:00.000Z"
23 },
24 "transaction": {
25 "transactionId": "transaction-uuid",
26 "status": "VERIFYING",
27 "gatewayReference": "gateway-ref",
28 "correlationId": "correlation-id",
29 "payerName": "Customer Name",
30 "payerEmail": "customer@example.com",
31 "payerMsisdn": "2376XXXXXXXX",
32 "payerComment": "Please deliver after 5 PM"
33 }
34}Fee Display
The customer-paid amount is quote.totalAmount. If the merchant fee payer isPAYER, it includes gateway/platform fees. If the merchant fee payer isMERCHANT, it equals the product amount and fees are deducted from merchant settlement or wallet.
Status and Redirects
After payment starts, poll GET /public/v1/checkout/sessions/:id/status.
1{
2 "checkoutSession": {
3 "id": "checkout-session-uuid",
4 "status": "PAID",
5 "transactionId": "transaction-uuid",
6 "gatewayReference": "gateway-ref",
7 "failureReason": null,
8 "redirectUrl": "https://merchant.example.com/pay/success?checkout_session_id=checkout-session-uuid&status=paid&transaction_id=transaction-uuid",
9 "paidAt": "2026-04-23T10:08:00.000Z"
10 }
11}PENDING: session created and payment not started.PROCESSING: gateway request started and verification is running.PAID: payment succeeded.FAILED: payment failed.EXPIRED: session expired before payment started.CANCELLED: reserved for cancel flows.
When the backend returns redirectUrl, the hosted checkout page redirects the customer there after the final status is reached.
- For
PAID, backend returns the success URL with query params added. - For
FAILED,EXPIRED, orCANCELLED, backend returns the cancel URL when configured. - If
redirectUrlis null, keep the customer on the hosted page and show the final state.
Webhook Events
Hosted Checkout emits the existing payment webhooks plus checkout-specific events:
payment.succeededpayment.failedcheckout.session.paidcheckout.session.failed
Direct Webhook Signature
Reliable webhook endpoints can subscribe to checkout.session.paid andcheckout.session.failed. If webhookUrl is passed at session creation, backend also attempts a direct signed webhook notification to that URL.
1{
2 "checkout_session_id": "checkout-session-uuid",
3 "merchant_id": "merchant-uuid",
4 "transaction_id": "transaction-uuid",
5 "amount": "5000.00",
6 "currency": "XAF",
7 "gateway": "MTN_MOMO",
8 "payer_name": "Customer Name",
9 "payer_email": "customer@example.com",
10 "payer_msisdn": "2376XXXXXXXX",
11 "payer_comment": "Please deliver after 5 PM",
12 "gateway_reference": "gateway-ref",
13 "failure_reason": null,
14 "metadata": {
15 "orderId": "ORDER-1001"
16 },
17 "status": "PAID"
18}1X-Zo-Event: checkout.session.paid
2X-Zo-Timestamp: <unix_ms>
3X-Zo-Signature: <hmac_sha256(timestamp + "." + raw_body)>The direct webhook HMAC secret is the merchant secret for the session environment.
Hosted Page Frontend Checklist
- Implement the public route
/pay/:checkoutSessionId. - Load the initial session using
GET /public/v1/checkout/sessions/:id. - Show only the enabled gateways returned on the session.
- Collect payer MSISDN, optional name, optional email, and optional comment.
- Submit payment with
POST /public/v1/checkout/sessions/:id/pay. - Show the customer-paid amount from
quote.totalAmount. - Poll
GET /public/v1/checkout/sessions/:id/statusuntil a final state. - Show clear success, failure, and expired states.
- Redirect with the returned
redirectUrlwhen present.
Merchant Integration Documentation Checklist
- Explain that checkout session creation must be done from the merchant backend.
- Explain that browsers must not call
POST /api/v1/checkout/sessionsdirectly. - Explain that merchant API credentials and signing logic must stay server-side.
- Explain that the correct approved server/domain/IP must be used when creating sessions.
- Explain that the wrong domain or IP can cause
403 Access Forbidden. - Explain that the customer-facing payment page is the hosted
checkoutUrl. - Explain that merchants should redirect the customer to
checkoutUrl. - Explain that merchants can use webhooks and/or final redirect URLs for order updates.
- Explain that customer amount confirmation should be based on
quote.totalAmount.
Security Model
- The public hosted page does not decide which merchant owns the payment.
- The merchant backend creates the session through the protected API Gateway route.
- Backend derives
merchantIdandenvironmentfrom the authenticated API key. - The public page only receives a checkout session id.
- Backend loads the session from the database and validates status and expiry.
- A session is one-time: once it leaves
PENDING, the hosted page cannot start another payment on it.
DB Note
Hosted Checkout uses the checkout_sessions table from migrations/013_add_checkout_sessions.sql. Any environment missing that table must be migrated before Hosted Checkout can work there.