Hosted checkout: create a link and get paid

To collect a payment, call POST /api/v1/checkouts, take the returned checkout_url, then send it to the payer or redirect their browser to it. The payer scans VietQR and the money goes directly to your bank account. MONA Pay confirms the transfer, sends a CHECKOUT_PAID webhook and returns the payer to your return_url.

MONA Pay hosted checkout showing VietQR, amount and order code while awaiting payment
A live pay.monapay.vn hosted checkout with VietQR, the amount, order code and time remaining. Captured on a test store.

The webhook or GET /checkouts/{id} is the source of truth before fulfilment. The return redirect is only a user-experience signal.

The five-step integration flow

  1. Set up the payment profile once. Call PUT /payment-profile with the shop identity, default bank account or VA and the QR fields. The API returns return_signature_secret only once, when the profile is created or the secret is rotated.
  2. Create a checkout for each order. Call POST /checkouts with the amount, order code and return_url. Send X-Client-Secret and Idempotency-Key so a retry cannot create a duplicate.
  3. Open the hosted payment page. Redirect the browser or send checkout_url over Zalo, Facebook or email. The link is https://pay.monapay.vn/c/<token> and needs no login.
  4. Confirm only after CHECKOUT_PAID. Verify the webhook HMAC against the raw body, deduplicate by transaction_code, and compare the order code and amount. You can call GET /checkouts/{id} for server-side reconciliation.
  5. Verify the return redirect. Check sig with return_signature_secret, validate ts, then call GET /checkouts/{id} again. Never fulfil from the status=paid query string alone.

A checkout expires after 900 seconds by default. MONA Pay marks it paid only when the matched total is at least the checkout amount. partial_amount is the total received for the checkout, not only an underpayment. For example, a 250,000 VND checkout that receives 200,000 VND has status: "pending", paid_amount: null and partial_amount: 200000; a 680,000 VND checkout paid in full has status: "paid", paid_amount: 680000 and partial_amount: 680000. Detect an underpayment with status == "pending" && partial_amount > 0, not with partial_amount > 0 alone.

Endpoints

Base API: https://api.monapay.vn/api/v1.

Payment profile

Method Endpoint Auth Purpose
GET /payment-profile Bearer Read the profile and default receiving account
PUT /payment-profile Bearer + X-Client-Secret Create or update the profile; the return secret is shown once
POST /payment-profile/reveal-return-secret Bearer + X-Client-Secret Reveal return_signature_secret after password or 2FA confirmation
POST /payment-profile/rotate-return-secret Bearer + X-Client-Secret Rotate return_signature_secret

If the profile is missing, checkout creation returns HTTP 422 with detail: "payment_profile_missing". Set it under Settings → Hosted checkout or call monapay_set_payment_profile through MCP.

Lost return_signature_secret?

Call POST /api/v1/payment-profile/reveal-return-secret with password or totp_code to confirm the account owner and reveal it again. If confirmation is unavailable or the secret may be compromised, call /payment-profile/rotate-return-secret; store the new value immediately and update the environment before verifying the next redirect.

Merchant checkout endpoints

Method Endpoint Purpose
POST /checkouts Create a checkout with Idempotency-Key, returns HTTP 201
GET /checkouts?status&order_code&from_date&to_date&page&limit Filter and paginate checkouts
GET /checkouts/{id} Get one checkout by ID
POST /checkouts/{id}/cancel Cancel a pending checkout
POST /checkouts/{id}/expire-now Expire immediately, for admin or testing only

amount is an integer from 1,000 to 1,000,000,000 VND. order_code is 1 to 50 characters using letters, numbers, _ or -, and must be unique among pending checkouts. return_url and cancel_url must use HTTPS. expires_in accepts 60 to 86,400 seconds and defaults to 900. metadata is limited to 2 KB.

To test before linking a bank or without moving real money, add sandbox: true; see Sandbox for exact-payment, underpayment and redelivery cases.

Public hosted-page endpoints

These endpoints need no auth. They accept only the random 32-character token and never expose the client ID, internal checkout ID or payer email.

Method Endpoint Purpose
GET /checkouts/public/{token} Data displayed by the hosted page
GET /checkouts/public/{token}/status Poll status, paid_at, expires_at, seconds_left
GET /checkouts/public/{token}/qr.png?size=512 QR as PNG, cached for five minutes
POST /checkouts/public/{token}/cancel Cancel a pending checkout and return to cancel_url
GET /qr/{qr_id}/image.png?size=512 PNG image for any generated QR

Public responses use Cache-Control: no-store, except QR images, and are limited to 60 requests per minute per IP.

cURL: create a checkout

This example assumes $TOKEN contains a Bearer token and $MONAPAY_CLIENT_SECRET contains the API client secret:

curl -s -X POST https://api.monapay.vn/api/v1/checkouts \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Client-Secret: $MONAPAY_CLIENT_SECRET" \
  -H "Idempotency-Key: checkout-ORDER10234" \
  -H 'Content-Type: application/json' \
  -d '{"amount":250000,"order_code":"ORDER10234","description":"Payment for ORDER10234","return_url":"https://shop.example/payment/return","cancel_url":"https://shop.example/checkout","payer_email":"[email protected]","expires_in":900}' \
  | jq '.data | {id, checkout_url, status, expires_at}'

Node.js: create, handle CHECKOUT_PAID, verify the redirect

import { createHmac, timingSafeEqual } from 'node:crypto';
import { MonaPay, verifyWebhook } from '@monapay/node';

const mona = MonaPay.fromEnv();
const checkout = await mona.checkouts.create({
  amount: 250000,
  order_code: 'ORDER10234',
  description: 'Payment for ORDER10234',
  return_url: 'https://shop.example/payment/return',
  cancel_url: 'https://shop.example/checkout',
});
console.log(checkout.checkout_url);

const verified = verifyWebhook({ rawBody, headers, secret: process.env.MONA_WEBHOOK_SECRET });
if (!verified.ok) throw new Error(verified.reason);
const event = verified.payload.event || verified.payload.event_type;
if (event === 'CHECKOUT_PAID') {
  await saveOnce(verified.payload.transaction_code, verified.payload);
  const current = await mona.checkouts.get(verified.payload.checkout_id);
  if (current.status === 'paid') await markOrderPaid(current.order_code);
}

const message = `${query.monapay_checkout}|${query.order_code}|paid|${query.ts}`;
const expected = createHmac('sha256', process.env.MONAPAY_RETURN_SECRET).update(message).digest('hex');
const supplied = String(query.sig || '');
const validSig = supplied.length === expected.length && timingSafeEqual(Buffer.from(supplied), Buffer.from(expected));
if (!validSig || Math.abs(Date.now() / 1000 - Number(query.ts)) > 300) throw new Error('Invalid redirect');
const current = await mona.checkouts.get(query.monapay_checkout);
if (current.status !== 'paid') throw new Error('Checkout is not paid');

MONA_WEBHOOK_SECRET belongs to the webhook configuration. MONAPAY_RETURN_SECRET is the payment profile’s return_signature_secret. They are separate secrets.

Python: create, handle CHECKOUT_PAID, verify the redirect

import hashlib
import hmac
import os
import time
from monapay import MonaPay, verify_webhook

mona = MonaPay.from_env()
checkout = mona.checkouts.create({
    "amount": 250000,
    "order_code": "ORDER10234",
    "description": "Payment for ORDER10234",
    "return_url": "https://shop.example/payment/return",
    "cancel_url": "https://shop.example/checkout",
})
print(checkout["checkout_url"])

verified = verify_webhook(raw_body, headers, os.environ["MONA_WEBHOOK_SECRET"])
if not verified.ok:
    raise ValueError(verified.reason)
event = verified.payload.get("event") or verified.payload.get("event_type")
if event == "CHECKOUT_PAID":
    save_once(verified.payload["transaction_code"], verified.payload)
    current = mona.checkouts.get(verified.payload["checkout_id"])
    if current["status"] == "paid":
        mark_order_paid(current["order_code"])

message = "{}|{}|paid|{}".format(query["monapay_checkout"], query["order_code"], query["ts"])
expected = hmac.new(os.environ["MONAPAY_RETURN_SECRET"].encode(), message.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, query.get("sig", "")) or abs(time.time() - int(query["ts"])) > 300:
    raise ValueError("Invalid redirect")
current = mona.checkouts.get(query["monapay_checkout"])
if current["status"] != "paid":
    raise ValueError("Checkout is not paid")

CHECKOUT_PAID payload

The event uses the existing X-Mona-Signature and X-Mona-Timestamp webhook signature. Its payload contains a compact checkout, including order_code, amount, paid_amount, paid_at and transaction_code. Store transaction_code with a unique constraint so webhook retries cannot fulfil twice.

{"event":"CHECKOUT_PAID","checkout_id":"01f1a785-050e-7294-8f44-e37b23e629bb","order_code":"DH1001","status":"paid","amount":680000,"currency":"VND","paid_amount":680000,"paid_at":"2026-09-03T10:48:53.834012+00:00","transaction_code":"SBX-DH1001-01","metadata":null}

Return redirect

After the checkout becomes paid, the hosted page waits about three seconds and appends these fields to return_url:

?monapay_checkout=<id>&order_code=<order_code>&status=paid&ts=<unix>&sig=<hex>

The signature is an HMAC-SHA256 hex digest over <id>|<order_code>|paid|<ts>. A cancellation returns to cancel_url?monapay_checkout=<id>&status=cancelled without a signature.

FAQ

Does money pass through MONA Pay?

No. Money moves directly from the payer’s bank account to your bank account. MONA Pay creates the QR, receives the bank notification and confirms the transfer; it never holds the money.

What happens when a checkout expires?

It expires after 15 minutes by default and no replacement QR is created. If the payer transfers later, MONA Pay still records the transaction and marks the checkout paid with paid_late=true; the merchant decides whether to accept or refund it.

What if the payer underpays?

partial_amount is the total received for the checkout. A 250,000 VND checkout that has received 200,000 VND has status: "pending", paid_amount: null and partial_amount: 200000; once a 680,000 VND checkout is paid in full, both paid_amount and partial_amount are 680000. Detect an underpayment with status == "pending" && partial_amount > 0, not with partial_amount > 0 alone.

Yes. Create it in the dashboard or with monapay_create_checkout, then send checkout_url over Zalo, Facebook or email. The payer opens the link and scans the QR without a MONA Pay account.