Trang thanh toán: tạo link là thu tiền

Cập nhật 04/09/2026Bản Markdownllms.txtopenapi.json

Video hướng dẫn

Trang thanh toán MONA Pay: tạo link là thu tiền · Xem trên YouTube
Xem transcript video (9 phần)

Tạo link, xác nhận đúng

Bạn muốn gửi một link để khách quét QR nhưng vẫn cần hệ thống xác nhận đúng đơn, đúng tiền và không tin nhầm trang chuyển hướng. Video này mình tạo trang thanh toán được lưu trữ sẵn, gọi tắt là checkout, nhận checkout_url, xử lý CHECKOUT_PAID và kiểm chữ ký khi khách quay lại. Mình cũng chạy cả ca đủ tiền, thiếu tiền và hết hạn.

Thiết lập hồ sơ thanh toán

Trước tiên thiết lập hồ sơ thanh toán một lần bằng PUT /api/v1/payment-profile, gồm tên shop, tài khoản nhận mặc định và bộ thông tin tạo QR. Response trả return_signature_secret đúng một lần khi tạo hoặc xoay. Nếu chưa có hồ sơ, tạo checkout trả bốn trăm hai mươi hai với payment_profile_missing. Bạn cũng thiết lập được ở dashboard, mục Cài đặt rồi Trang thanh toán.

Tạo checkout cho đơn

Cho từng đơn, gọi POST /api/v1/checkouts với Bearer, X-Client-Secret và Idempotency-Key. Body mẫu gồm amount, order_code, description, return_url, cancel_url, email người trả và expires_in. amount từ một nghìn đến một tỷ đồng. Mã đơn dài tối đa năm mươi ký tự. Hai URL quay về phải dùng HTTPS. expires_in mặc định chín trăm giây.

Đưa khách sang trang QR

Response HTTP hai trăm lẻ một trả checkout_url dạng https://pay.monapay.vn/c/<token>. Bạn chuyển hướng trình duyệt hoặc gửi link qua kênh đang bán hàng. Khách không cần đăng nhập, chỉ thấy VietQR, số tiền, mã đơn và thời gian còn lại. Các endpoint public đọc trạng thái và ảnh QR dùng token ngẫu nhiên ba mươi hai ký tự, không lộ ID nội bộ hay email người trả. API trạng thái còn trả paid_at, expires_at và seconds_left.

Xử lý CHECKOUT_PAID

Nguồn xác nhận đơn là webhook CHECKOUT_PAID hoặc GET /checkouts/{id}, không phải query string khi trình duyệt quay lại. Payload có checkout_id, order_code, status, amount, paid_amount, paid_at và transaction_code. Kiểm X-Mona-Signature trên raw body, lưu transaction_code bằng khóa UNIQUE, rồi gọi GET kiểm status là paid trước khi đổi trạng thái đơn.

Kiểm chữ ký redirect

Khi khách quay lại return_url, MONA Pay nối monapay_checkout, order_code, status, ts và sig sau khoảng ba giây. Chữ ký là HMAC-SHA256 của chuỗi ID, mã đơn, chữ paid và timestamp, ngăn cách bằng dấu gạch đứng. So sig bằng return_signature_secret, từ chối timestamp lệch quá năm phút, rồi vẫn gọi GET để kiểm tra. Redirect hủy về cancel_url không có chữ ký.

Hết hạn và thiếu tiền

Checkout mặc định hết hạn sau mười lăm phút và không tạo QR mới. Nếu khách trả muộn, giao dịch vẫn được ghi nhận và phiên có thể thành paid cùng paid_late bằng true; bạn tự quyết định nhận đơn hay hoàn tiền. Với trả thiếu, status vẫn pending, paid_amount là null và partial_amount là tổng đã nhận. Checkout chỉ thành paid khi tổng nhận lớn hơn hoặc bằng số tiền yêu cầu. Chỉ phát hiện thiếu khi status là pending và partial_amount lớn hơn không.

Thử sandbox và giữ Secret

Muốn thử không chuyển tiền, thêm sandbox bằng true khi tạo phiên rồi dùng giao dịch sandbox nạp đủ hoặc thiếu. Nếu mất return_signature_secret, gọi endpoint reveal với mật khẩu hoặc mã TOTP để xem lại; nếu nghi lộ thì xoay Secret và cập nhật biến môi trường. Luôn dùng Idempotency-Key để request thử lại không tạo hai phiên cho một đơn. Đừng giao hàng chỉ vì URL có status=paid.

Mở docs và chạy thử

Luồng năm bước, cURL, Node, Python và payload mẫu nằm tại monapay.vn/docs/api/trang-thanh-toan. Bạn đăng ký tại my.monapay.vn, thiết lập hồ sơ rồi tạo một checkout sandbox để đi hết luồng trước khi thu tiền thật. Nếu còn vướng chữ ký quay về hoặc trạng thái thiếu tiền, gọi tổng đài một chín không không, sáu ba sáu, sáu bốn tám để đội MONA hỗ trợ.

Muốn thu một khoản tiền, bạn gọi POST /api/v1/checkouts, lấy checkout_url rồi gửi link cho khách hoặc chuyển hướng trình duyệt sang link đó. Khách quét VietQR, tiền vào thẳng tài khoản ngân hàng của bạn. MONA Pay xác nhận giao dịch, gửi webhook CHECKOUT_PAID và đưa khách về return_url.

Trang thanh toán MONA Pay hiển thị VietQR, số tiền và mã đơn đang chờ thanh toán
Trang hosted checkout thật tại pay.monapay.vn: khách thấy VietQR, số tiền, mã đơn và thời gian còn lại. Ảnh chụp trên cửa hàng thử nghiệm.

Webhook hoặc GET /checkouts/{id} là nguồn sự thật trước khi giao hàng. Redirect về website chỉ phục vụ trải nghiệm người dùng.

Luồng tích hợp

  1. Thiết lập hồ sơ thanh toán một lần. Gọi PUT /payment-profile với tên shop, tài khoản ngân hàng hoặc VA mặc định và bộ thông tin tạo QR. API trả return_signature_secret đúng một lần khi tạo hoặc xoay secret.
  2. Tạo phiên cho từng đơn. Gọi POST /checkouts với số tiền, mã đơn và return_url. Gửi cả X-Client-Secret và Idempotency-Key để tránh tạo trùng khi request được thử lại.
  3. Đưa khách sang trang thanh toán. Chuyển hướng trình duyệt hoặc gửi checkout_url qua Zalo, Facebook, email. Link có dạng https://pay.monapay.vn/c/<token> và không yêu cầu đăng nhập.
  4. Chỉ xác nhận đơn khi nhận CHECKOUT_PAID. Kiểm chữ ký HMAC webhook trên raw body, chống trùng bằng transaction_code, so lại mã đơn và số tiền. Có thể gọi GET /checkouts/{id} để đối soát server-side.
  5. Xác minh redirect khi khách quay lại. Kiểm sig bằng return_signature_secret, kiểm thời gian ts, rồi gọi lại GET /checkouts/{id}. Không giao hàng chỉ dựa vào query string status=paid.

Phiên mặc định hết hạn sau 900 giây. MONA Pay chỉ đánh dấu paid khi tổng tiền khớp lớn hơn hoặc bằng số tiền của checkout. partial_amount là tổng tiền đã nhận được cho phiên, không chỉ là phần tiền thiếu. Ví dụ, checkout 250.000đ nhận 200.000đ vẫn có status: "pending", paid_amount: null, partial_amount: 200000; checkout 680.000đ nhận đủ có status: "paid", paid_amount: 680000 và partial_amount: 680000. Muốn phát hiện thiếu tiền, hãy kiểm tra status == "pending" && partial_amount > 0, không chỉ kiểm tra partial_amount > 0.

Endpoint

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

Hồ sơ thanh toán

Method Endpoint Auth Dùng để
GET /payment-profile Bearer Đọc hồ sơ và tài khoản nhận tiền mặc định
PUT /payment-profile Bearer + X-Client-Secret Tạo hoặc cập nhật hồ sơ; secret redirect chỉ hiện một lần
POST /payment-profile/reveal-return-secret Bearer + X-Client-Secret Xem lại return_signature_secret sau khi xác nhận mật khẩu hoặc 2FA
POST /payment-profile/rotate-return-secret Bearer + X-Client-Secret Xoay return_signature_secret

Nếu chưa có hồ sơ, tạo checkout trả HTTP 422 với detail: "payment_profile_missing". Bạn thiết lập ở dashboard, mục Cài đặt → Trang thanh toán, hoặc gọi MCP tool monapay_set_payment_profile.

Mất return_signature_secret?

Gọi POST /api/v1/payment-profile/reveal-return-secret với password hoặc totp_code để xác nhận đúng chủ tài khoản rồi xem lại secret. Nếu không thể xác nhận hoặc nghi secret đã lộ, gọi /payment-profile/rotate-return-secret để xoay; lưu secret mới ngay và cập nhật biến môi trường trước khi xác minh redirect tiếp theo.

Phiên thanh toán của merchant

Method Endpoint Dùng để
POST /checkouts Tạo checkout, cần Idempotency-Key, trả HTTP 201
GET /checkouts?status&order_code&from_date&to_date&page&limit Lọc và phân trang checkout
GET /checkouts/{id} Đọc một checkout theo ID
POST /checkouts/{id}/cancel Huỷ phiên đang pending
POST /checkouts/{id}/expire-now Hết hạn ngay, chỉ dành cho admin hoặc test

amount là số nguyên VND từ 1.000 đến 1.000.000.000. order_code dài 1 đến 50 ký tự, chỉ gồm chữ, số, _, -, và không trùng trong các phiên đang chờ. return_url và cancel_url phải dùng HTTPS. expires_in nhận 60 đến 86.400 giây, mặc định 900 giây. metadata tối đa 2 KB.

Muốn thử mà chưa nối ngân hàng hoặc không chuyển tiền thật, thêm sandbox: true; xem Sandbox để tạo giao dịch đủ tiền, thiếu tiền và gửi lại.

Endpoint public cho trang checkout

Các endpoint sau không cần auth, chỉ nhận token ngẫu nhiên 32 ký tự. Response không lộ client ID, ID nội bộ hoặc email người trả.

Method Endpoint Dùng để
GET /checkouts/public/{token} Dữ liệu hiển thị trang thanh toán
GET /checkouts/public/{token}/status Poll trạng thái, paid_at, expires_at, seconds_left
GET /checkouts/public/{token}/qr.png?size=512 Ảnh QR PNG, cache 5 phút
POST /checkouts/public/{token}/cancel Huỷ phiên đang chờ và về cancel_url
GET /qr/{qr_id}/image.png?size=512 Ảnh PNG cho mọi QR đã tạo

Public API có Cache-Control: no-store, trừ ảnh QR, và giới hạn 60 request mỗi phút trên mỗi IP.

cURL: tạo phiên thanh toán

Ví dụ đã có Bearer token trong $TOKEN và client secret trong $MONAPAY_CLIENT_SECRET:

Terminal
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-DH10234" \
  -H 'Content-Type: application/json' \
  -d '{"amount":250000,"order_code":"DH10234","description":"Thanh toan DH10234","return_url":"https://shop.vn/payment/return","cancel_url":"https://shop.vn/checkout","payer_email":"[email protected]","expires_in":900}' \
  | jq '.data | {id, checkout_url, status, expires_at}'

Node.js: tạo phiên, xử lý CHECKOUT_PAID, kiểm redirect

JavaScript
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: 'DH10234',
  description: 'Thanh toan DH10234',
  return_url: 'https://shop.vn/payment/return',
  cancel_url: 'https://shop.vn/checkout',
});
console.log(checkout.checkout_url);

// Route webhook phải giữ rawBody đúng byte MONA Pay gửi.
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);
}

// Route return: sig = HMAC-SHA256(secret, "<id>|<order_code>|paid|<ts>").
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('Redirect không hợp lệ');
const current = await mona.checkouts.get(query.monapay_checkout);
if (current.status !== 'paid') throw new Error('Checkout chưa thanh toán');

MONA_WEBHOOK_SECRET là secret của cấu hình webhook. MONAPAY_RETURN_SECRET là return_signature_secret của hồ sơ thanh toán. Đây là hai secret khác nhau.

Python: tạo phiên, xử lý CHECKOUT_PAID, kiểm redirect

Python
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": "DH10234",
    "description": "Thanh toan DH10234",
    "return_url": "https://shop.vn/payment/return",
    "cancel_url": "https://shop.vn/checkout",
})
print(checkout["checkout_url"])

# Route webhook: raw_body phải là bytes nguyên bản.
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"])

# Route return: luôn gọi API kiểm trạng thái sau khi sig hợp lệ.
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("Redirect không hợp lệ")
current = mona.checkouts.get(query["monapay_checkout"])
if current["status"] != "paid":
    raise ValueError("Checkout chưa thanh toán")

Payload CHECKOUT_PAID

Event dùng cùng chữ ký X-Mona-Signature và X-Mona-Timestamp như webhook giao dịch hiện có. Payload chứa dữ liệu checkout rút gọn, order_code, amount, paid_amount, paid_at và transaction_code. Lưu transaction_code bằng unique constraint để việc gửi lại webhook không xử lý đơn hai lần.

JSON
{"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}

Redirect về website

Sau khi checkout chuyển sang paid, trang thanh toán đợi khoảng 3 giây rồi nối các tham số sau vào return_url:

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

Chữ ký là HMAC-SHA256 dạng hex với message <id>|<order_code>|paid|<ts>. Khi khách huỷ, MONA Pay về cancel_url?monapay_checkout=<id>&status=cancelled; redirect huỷ không có chữ ký.

Câu hỏi thường gặp

Tiền có đi qua MONA Pay không?

Không. Tiền đi thẳng từ tài khoản ngân hàng của khách vào tài khoản ngân hàng của bạn. MONA Pay tạo QR, nhận báo có và xác nhận giao dịch, không giữ tiền.

Checkout hết hạn thì sao?

Phiên mặc định hết hạn sau 15 phút và không tạo QR mới. Nếu khách chuyển sau hạn, MONA Pay vẫn ghi nhận giao dịch, đổi phiên sang paid với paid_late=true; merchant tự quyết định nhận đơn hay hoàn tiền.

Khách chuyển thiếu tiền thì sao?

partial_amount là tổng tiền đã nhận được cho phiên. Khi checkout 250.000đ mới nhận 200.000đ, phiên có status: "pending", paid_amount: null và partial_amount: 200000; khi checkout 680.000đ nhận đủ, cả paid_amount và partial_amount đều là 680000. Vì vậy, hãy phát hiện thiếu tiền bằng status == "pending" && partial_amount > 0, không chỉ bằng partial_amount > 0.

Được. Bạn tạo link trong dashboard hoặc dùng monapay_create_checkout, rồi gửi checkout_url qua Zalo, Facebook hoặc email. Khách mở link, quét QR và thanh toán, không cần tài khoản MONA Pay.