Tích hợp webhook thanh toán: nhận tiền vào ACB trong 5 phút

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

Video hướng dẫn

Tích hợp webhook thanh toán: nhận tiền vào ACB trong 5 phút · Xem trên YouTube
Xem transcript video (9 phần)

Webhook trong năm phút

Khách đã chuyển khoản nhưng đơn vẫn nằm chờ vì hệ thống của bạn chưa nhận được báo có. Trong khoảng năm phút, mình sẽ đi trọn luồng webhook: khai báo URL, nhận đúng bảy trường JSON, kiểm chữ ký, trả mã thành công và chống xử lý trùng. Cuối video, bạn còn biết cách thử endpoint và đọc lỗi trước khi đưa lên môi trường thật.

Luồng tiền đến endpoint

Luồng bắt đầu khi tiền vào tài khoản ACB qua tài khoản ảo, VietQR hoặc chuyển khoản thường. ACB báo giao dịch cho MONA Pay. Hệ thống lưu giao dịch, tìm các cấu hình webhook phù hợp rồi gửi HTTP POST đến URL của bạn. Cấu hình “mọi tài khoản” nhận mọi giao dịch. Cấu hình gắn một tài khoản ảo chỉ nhận tiền vào đúng tài khoản đó. Máy chủ có tối đa mười giây để phản hồi.

Khai báo URL webhook

Trong dashboard, vào mục Webhooks rồi bấm Thêm webhook. Đặt Tên dễ nhớ, chẳng hạn Web bán hàng. Trường URL phải là địa chỉ HTTPS công khai. Kiểu xác thực nên chọn HMAC_SHA256, nhập Secret riêng. Định dạng gửi mặc định là application/json. Ở Tài khoản áp dụng, chọn mọi tài khoản hoặc một tài khoản ảo cụ thể. Nếu đang làm tại máy, dùng đường hầm HTTPS vì hệ thống không gọi được localhost.

Đọc bảy trường payload

Payload mặc định có bảy trường. amount là số tiền VND dạng số nguyên. description là nội dung chuyển khoản. transfer_date là thời điểm giao dịch. transaction_code là mã giao dịch ngân hàng và cũng là khóa chống trùng. account_number là số tài khoản thật hoặc tài khoản ảo nhận tiền. bank_name là tên ngân hàng. type hiện mang giá trị income, nghĩa là tiền vào. Endpoint phải bỏ qua trường lạ được thêm sau này.

Kiểm chữ ký, trả sớm

Ở endpoint, thứ tự xử lý rất quan trọng. Đọc raw body nguyên bản trước khi bộ phân tích JSON thay đổi khoảng trắng hoặc thứ tự khóa. Lấy X-Mona-Timestamp và X-Mona-Signature, tính lại HMAC-SHA256 bằng Secret, rồi so chữ ký bằng hàm thời gian cố định. Từ chối timestamp lệch quá năm phút. Chữ ký đúng thì trả HTTP hai trăm ngay; cập nhật đơn, gửi email hoặc gọi dịch vụ khác hãy đẩy sang hàng đợi.

Chống xử lý trùng

Sau khi trả lời, giải mã JSON và chèn giao dịch vào bảng của bạn. Cột transaction_code phải có ràng buộc UNIQUE. Với MySQL có thể dùng INSERT IGNORE; với PostgreSQL dùng ON CONFLICT DO NOTHING. Nếu chèn không thành công, đây là gói đã xử lý nên dừng. Khi là gói mới, khớp đơn bằng account_number nếu mỗi đơn có một tài khoản ảo, hoặc bằng description, rồi mới đổi trạng thái đơn.

Kiểm tra trước production

Trước khi chạy thật, bấm Gửi thử và mở tab Lịch sử gửi. Kết quả hợp lệ là HTTP hai trăm, hai trăm lẻ một hoặc hai trăm lẻ hai, thời gian phản hồi dưới một giây. Endpoint phải dùng HTTPS có chứng chỉ hợp lệ, không chuyển hướng và tường lửa phải cho phép IP 103.168.55.14. Cuối cùng, chuyển một khoản nhỏ vào tài khoản ảo để kiểm tra trọn luồng từ ngân hàng đến đơn hàng.

Sửa lỗi webhook

Nếu chữ ký sai dù Secret đúng, gần như raw body đã bị framework phân tích rồi tạo lại. Nếu thấy TIMEOUT, endpoint đang làm việc nặng trước khi trả lời; hãy trả hai trăm trước. HTTP_4XX với bốn trăm lẻ bốn thường là sai đường dẫn, còn bốn trăm lẻ năm là route không nhận POST. Không nhận gì thì kiểm tra cấu hình có đang gắn nhầm tài khoản ảo và xem Lịch sử gửi.

Mở tài liệu và thử

Mẫu PHP, Node, lệnh thử cURL và toàn bộ danh sách kiểm tra nằm tại monapay.vn/docs/webhooks/tich-hop-webhook. Bạn có thể đăng ký tài khoản tại my.monapay.vn, tạo cấu hình thử rồi đối chiếu từng bước trong tài liệu. Nếu endpoint vẫn chưa nhận đúng payload, 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ợ kiểm tra.

Webhook MONA Pay là một request HTTP POST gửi tới URL của bạn mỗi khi có tiền vào tài khoản ACB đã nối. Để tích hợp, bạn khai báo URL trong dashboard hoặc qua API, viết một endpoint nhận JSON, kiểm chữ ký, trả HTTP 200 trong 10 giây rồi xử lý đơn hàng. Phần này mất khoảng 30 dòng code, có mẫu PHP và Node dán vào dùng ngay.

Luồng một webhook

  1. Tiền vào tài khoản ACB (qua VA, VietQR hoặc chuyển khoản thường).
  2. ACB báo giao dịch cho MONA Pay, MONA Pay lưu vào dashboard.
  3. MONA Pay tìm các cấu hình webhook khớp: cấu hình “mọi tài khoản” luôn được gửi, cấu hình gắn một VA chỉ gửi khi giao dịch thuộc VA đó.
  4. Với mỗi cấu hình, MONA Pay POST payload tới URL, kèm chữ ký nếu bật HMAC, chờ tối đa 10 giây.
  5. Máy chủ của bạn trả HTTP 200, 201 hoặc 202: ghi log thành công. Mã khác hoặc quá 10 giây: ghi log thất bại kèm nhãn lỗi, bạn gửi lại từ dashboard.

Khai báo URL webhook

Trong dashboard

Vào mục Webhooks, bấm Thêm webhook, điền:

Trường Ý nghĩa
Tên Đặt cho dễ nhớ, ví dụ “Web bán hàng”, “Phần mềm học phí”
URL Địa chỉ HTTPS nhận webhook trên máy chủ của bạn
Kiểu xác thực HMAC_SHA256 (khuyên dùng), API_KEY hoặc NONE
Secret Chuỗi bí mật dùng để ký (HMAC) hoặc gửi kèm header (API key)
Định dạng gửi application/json (mặc định), application/x-www-form-urlencoded, multipart/form-data
Tài khoản áp dụng Mọi tài khoản, hoặc chọn một VA cụ thể
Dashboard MONA Pay quản lý webhook Shopify đang hoạt động và các sự kiện đã chọn
Mỗi webhook hiện URL, định dạng, tài khoản và sự kiện đang nhận, ảnh chụp từ dashboard my.monapay.vn.

Qua API

Terminal
curl -X POST https://api.monapay.vn/api/v1/client-webhooks \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Client-Secret: $CLIENT_SECRET" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Web bán hàng",
    "webhook_url": "https://ten-mien-cua-anh-chi.vn/webhook/monapay",
    "auth_type": "HMAC_SHA256",
    "secret_key": "secret_hmac_cua_anh_chi",
    "payload_format": "application/json",
    "virtual_account_id": null
  }'

Các lệnh liên quan: GET /api/v1/client-webhooks xem danh sách, PUT /api/v1/client-webhooks/{config_id} sửa, DELETE /api/v1/client-webhooks/{config_id} xoá, POST /api/v1/client-webhooks/test gửi gói mẫu. Chi tiết tham số ở Cấu hình webhook qua API.

Chạy webhook trên localhost

Webhook cần một URL HTTPS công khai, MONA Pay không thể gọi trực tiếp vào localhost.

Terminal
cloudflared tunnel --url http://localhost:4400

Dán URL HTTPS do tunnel cấp vào webhook_url; bạn cũng có thể dùng ngrok. Nếu chưa có URL công khai, hãy hỏi lại GET /api/v1/checkouts/{checkout_id} mỗi vài giây trong lúc thử. Trang Địa chỉ IP chỉ áp dụng khi đưa hệ thống lên production.

Payload MONA Pay gửi

JSON
{
  "amount": 2500000,
  "description": "noi dung chuyen khoan",
  "transfer_date": "10:30:00 28/08/2026",
  "transaction_code": "FT26240001234",
  "account_number": "1234567890",
  "bank_name": "ACB",
  "type": "income"
}

transaction_code giữ nguyên qua mọi lần gửi lại, dùng làm khoá chống trùng. account_number là số VA hoặc số tài khoản nhận tiền. Từng trường giải thích ở Định dạng payload.

Viết endpoint nhận webhook

Endpoint tốt làm đúng 3 việc theo thứ tự: kiểm chữ ký, trả 200 ngay, xử lý sau. Việc nặng (cập nhật đơn, gửi email, gọi API khác) đẩy vào hàng đợi hoặc làm sau khi đã trả lời, để không bao giờ chạm mốc 10 giây.

Terminal
# Giả lập MONA Pay bắn webhook vào endpoint của bạn để test tại chỗ
SECRET='secret_hmac_cua_anh_chi'   # secret HMAC đặt khi bật HMAC trong dashboard
URL='https://ten-mien-cua-anh-chi.vn/webhook/monapay'
TS=$(date +%s)
BODY='{"amount":2500000,"description":"noi dung ck","transfer_date":"10:30:00 28/08/2026","transaction_code":"FT26240001234","account_number":"1234567890","bank_name":"ACB","type":"income"}'

# Chữ ký = HMAC-SHA256(secret, "<timestamp>.<raw_body>"), dùng printf để không dính ký tự xuống dòng
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

curl -X POST "$URL" \
  -H 'Content-Type: application/json' \
  -H "X-Mona-Timestamp: $TS" \
  -H "X-Mona-Signature: sha256=$SIG" \
  --data "$BODY"
PHP
<?php
// webhook-monapay.php: endpoint nhận thông báo tiền vào từ MONA Pay
$secret = getenv('MONA_WEBHOOK_SECRET'); // secret HMAC đặt khi bật HMAC

$raw       = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_MONA_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_MONA_SIGNATURE'] ?? ''; // dạng "sha256=<hex>"

// 1. Chặn replay: timestamp lệch quá 5 phút thì từ chối
if (abs(time() - (int) $timestamp) > 300) {
    http_response_code(400);
    exit('timestamp qua han');
}

// 2. Verify chữ ký: HMAC-SHA256(secret, "<timestamp>.<raw_body>")
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $raw, $secret);
if (!hash_equals($expected, $signature)) { // hash_equals chống timing attack
    http_response_code(401);
    exit('sai chu ky');
}

// 3. Trả 200 ngay, việc nặng đẩy sang xử lý sau (queue/cron)
http_response_code(200);
echo 'OK';
if (function_exists('fastcgi_finish_request')) fastcgi_finish_request();

$data = json_decode($raw, true);

// 4. Chống trùng: transaction_code là khoá duy nhất
$pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'));
$stmt = $pdo->prepare(
    'INSERT IGNORE INTO giao_dich (transaction_code, amount, description, transfer_date, account_number)
     VALUES (?, ?, ?, ?, ?)'
);
$stmt->execute([$data['transaction_code'], $data['amount'], $data['description'], $data['transfer_date'], $data['account_number']]);
if ($stmt->rowCount() === 0) exit; // đã xử lý rồi, gói này là gửi lại

// TODO: khớp đơn hàng theo account_number (VA) hoặc description, đổi trạng thái đơn
JavaScript
// webhook-monapay.js: nhận webhook MONA Pay bằng Express
const express = require('express');
const crypto  = require('crypto');

const app    = express();
const SECRET = process.env.MONA_WEBHOOK_SECRET; // secret HMAC đặt khi bật HMAC

// Phải đọc raw body thì chữ ký mới khớp (đừng dùng express.json() ở route này)
app.post('/webhook/monapay', express.raw({ type: 'application/json' }), async (req, res) => {
  const timestamp = req.header('X-Mona-Timestamp') || '';
  const signature = req.header('X-Mona-Signature') || ''; // "sha256=<hex>"
  const rawBody   = req.body.toString('utf8');

  // 1. Chặn replay: timestamp lệch quá 5 phút thì từ chối
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.status(400).send('timestamp qua han');
  }

  // 2. Verify chữ ký: HMAC-SHA256(secret, "<timestamp>.<raw_body>")
  const expected = 'sha256=' + crypto
    .createHmac('sha256', SECRET)
    .update(timestamp + '.' + rawBody)
    .digest('hex');
  const hopLe =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  if (!hopLe) return res.status(401).send('sai chu ky');

  // 3. Trả 200 ngay, xử lý đơn hàng async sau đó
  res.status(200).send('OK');

  const data = JSON.parse(rawBody);

  // 4. Chống trùng: transaction_code là khoá duy nhất (ví dụ với bảng có UNIQUE(transaction_code))
  // const inserted = await db.insertIgnore('giao_dich', { transaction_code: data.transaction_code, ... });
  // if (!inserted) return; // gói gửi lại, đã xử lý trước đó

  // TODO: khớp đơn hàng theo data.account_number (VA) hoặc data.description, đổi trạng thái đơn
});

app.listen(3000, () => console.log('Webhook MONA Pay chạy ở cổng 3000'));

Chống trùng giao dịch

Cùng một giao dịch có thể tới endpoint của bạn nhiều lần: gửi lại thủ công từ dashboard, hai cấu hình webhook cùng trỏ một URL, hoặc máy chủ trả lỗi rồi được gửi lại. Cách xử lý chuẩn là tạo cột transaction_code có ràng buộc UNIQUE trong bảng giao dịch và dùng INSERT IGNORE (MySQL) hoặc ON CONFLICT DO NOTHING (PostgreSQL). Gói nào chèn không vào là gói đã xử lý, bỏ qua. Đừng dựa vào cặp số tiền + thời gian, hai khách chuyển cùng số tiền trong cùng phút là chuyện bình thường.

Kiểm tra trước khi lên production

  • Endpoint chạy HTTPS với chứng chỉ hợp lệ. Chứng chỉ tự ký hoặc hết hạn sẽ ra nhãn lỗi SSL.
  • Tường lửa cho phép IP 103.168.55.14 (xem Địa chỉ IP).
  • Không có redirect ở URL webhook (ví dụ từ http sang https hoặc thêm dấu / cuối). Redirect ra nhãn HTTP_3XX và tính là thất bại.
  • Bấm Gửi thử trong dashboard, xem Lịch sử gửi có mã 200 và thời gian phản hồi dưới 1 giây.
  • Chuyển thử một khoản nhỏ vào VA để chạy luồng thật.
Dashboard MONA Pay hiển thị lịch sử webhook với mã HTTP, thời lượng và nhãn lỗi
Lịch sử webhook cho thấy từng sự kiện, endpoint, mã HTTP, thời lượng và nhãn lỗi để xử lý, ảnh chụp từ dashboard my.monapay.vn.

Lỗi thường gặp

Webhook về nhưng chữ ký không khớp. Framework (Laravel, Express, Next.js) đã parse JSON rồi mới lấy body để ký. Phải lấy raw body nguyên bản. Xem cách lấy raw body từng framework ở Bảo mật webhook.

Nhãn TIMEOUT dù endpoint chạy được. Endpoint xử lý đơn xong mới trả lời, vượt 10 giây khi hệ thống bận. Trả 200 trước, xử lý sau.

Nhãn HTTP_4XX với mã 404 hoặc 405. Sai đường dẫn, hoặc route chỉ nhận GET. Webhook luôn là POST.

Nhận được 2 lần cùng một giao dịch. Bình thường. Chống trùng bằng transaction_code như mục trên.

Không nhận được gì dù tiền đã vào. Cấu hình webhook đang gắn một VA nhưng khách chuyển vào tài khoản chính hoặc VA khác. Tạo thêm một cấu hình “mọi tài khoản” để không sót, hoặc kiểm tra Lịch sử gửi trong dashboard xem MONA Pay có gửi không.