Quick start with MONA Pay in 5 minutes
To receive real-time notifications for money arriving in an ACB account you need 6 steps: sign up (usable immediately, no approval), log in for a token, create an API key, link your ACB account with an OTP, register a webhook URL, then send a small transfer to check. If you already have an ACB account and a server that can receive webhooks, the hands-on part takes about 5 minutes.
Before you start
- An ACB bank account in your name or your company’s name, plus the phone number registered with ACB (to receive the OTP).
- An HTTPS URL on your server to receive webhooks, for example
https://your-domain.com/webhook/monapay. No server yet? Telegram notifications still work, see Telegram. - A way to call the API: cURL, Postman, or let an AI agent do it with the ready-made prompt.
Step 1. Sign up
Go to https://my.monapay.vn/auth, open the Sign up tab and fill in a username, password, name and email. You can also call the API:
curl -X POST https://api.monapay.vn/api/v1/client/register-client \
-H 'Content-Type: application/json' \
-d '{"username":"my-shop","password":"a-strong-password","name":"My Shop"}'
The account is usable immediately: log in at step 2, nobody has to approve it. MONA Pay is completely free with no transaction limit (see pricing).
Step 2. Log in for a token
curl -X POST https://api.monapay.vn/api/v1/client/login \
-H 'Content-Type: application/json' \
-d '{"username":"my-shop","password":"a-strong-password"}'
Every API returns the same envelope:
{
"success": true,
"message": "Login successful",
"data": { "access_token": "eyJhbGciOi...", "expires_in": 86400 }
}
From here every call carries Authorization: Bearer <access_token>. Details in Authentication.
Step 3. Create an API key
POST, PUT and DELETE requests need an extra X-Client-Secret header. Create a key in the dashboard under API Keys, or:
curl -X POST https://api.monapay.vn/api/v1/client-keys/generate \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"production-server"}'
client_secret is shown exactly once in the response. Store it in an environment variable right away; if you lose it, create a new key and revoke the old one. See API keys.
Step 4. Link your ACB account (4 steps, with OTP)
In the dashboard, open Banks & VA and click Add account:
- Enter the ACB account number, the phone number registered with ACB, and the customer type (personal or business).
- ACB sends an OTP to the phone. Enter it to verify and create your first virtual account (VA).
- Register for transaction notifications. ACB sends a second OTP; enter it too.
- Done. From now on, money arriving in the VA or the account reaches MONA Pay as a notification.
To do this through the API instead of the dashboard, see Virtual accounts (VA); the flow is the same 4 calls.
Step 5. Register your webhook URL
In the dashboard open Webhooks, click Add webhook, enter the URL, pick HMAC_SHA256 as the auth type and set a secret. Or call the API:
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": "Online store",
"webhook_url": "https://your-domain.com/webhook/monapay",
"auth_type": "HMAC_SHA256",
"secret_key": "your_hmac_secret",
"payload_format": "application/json"
}'
Without virtual_account_id the webhook receives every transaction on every account. Pass the id of one VA to receive only that VA’s transactions.
On your side the endpoint does 3 things: verify the signature, answer HTTP 200 immediately, then process the order. Paste-ready samples:
<?php
// webhook-monapay.php: endpoint that receives incoming-payment events from MONA Pay
$secret = getenv('MONA_WEBHOOK_SECRET'); // the HMAC secret you set when enabling HMAC
$raw = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_MONA_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_MONA_SIGNATURE'] ?? ''; // "sha256=<hex>"
// 1. Block replays: reject if the timestamp is more than 5 minutes off
if (abs(time() - (int) $timestamp) > 300) {
http_response_code(400);
exit('timestamp expired');
}
// 2. Verify the signature: HMAC-SHA256(secret, "<timestamp>.<raw_body>")
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $raw, $secret);
if (!hash_equals($expected, $signature)) { // hash_equals prevents timing attacks
http_response_code(401);
exit('bad signature');
}
// 3. Answer 200 right away, push heavy work to a queue/cron
http_response_code(200);
echo 'OK';
if (function_exists('fastcgi_finish_request')) fastcgi_finish_request();
$data = json_decode($raw, true);
// $data['amount'], $data['description'], $data['transaction_code'], ...
// TODO: record the transaction and update your order here
// webhook-monapay.js: receive MONA Pay webhooks with Express
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET = process.env.MONA_WEBHOOK_SECRET; // the HMAC secret you set when enabling HMAC
// Read the raw body or the signature will not match (do not use express.json() on this route)
app.post('/webhook/monapay', express.raw({ type: 'application/json' }), (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. Block replays: reject if the timestamp is more than 5 minutes off
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send('timestamp expired');
}
// 2. Verify the signature: HMAC-SHA256(secret, "<timestamp>.<raw_body>")
const expected = 'sha256=' + crypto
.createHmac('sha256', SECRET)
.update(timestamp + '.' + rawBody)
.digest('hex');
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) return res.status(401).send('bad signature');
// 3. Answer 200 right away, process the order asynchronously
res.status(200).send('OK');
const data = JSON.parse(rawBody);
// data.amount, data.description, data.transaction_code, ...
// TODO: record the transaction and update your order here
});
app.listen(3000, () => console.log('MONA Pay webhook listening on port 3000'));
Step 6. Send a test, then a real transfer
First click Send test in the dashboard (Webhooks) or call POST /api/v1/client-webhooks/test. MONA Pay fires a sample payload at your URL; the result shows up immediately in Delivery history with the HTTP code and response time.
To test locally without waiting for MONA Pay, replay exactly what MONA Pay sends with cURL:
# Simulate MONA Pay firing a webhook at your endpoint for a local test
SECRET='your_hmac_secret' # the HMAC secret you set in the dashboard
URL='https://your-domain.com/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"}'
# Signature = HMAC-SHA256(secret, "<timestamp>.<raw_body>"); printf avoids a trailing newline
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"
Finally transfer a small amount, say 10,000 VND, into the VA you just created from any banking app. Within seconds the transaction appears on the dashboard and the webhook reaches your server.
After it works
- Read Payload format to learn every field and use
transaction_codeas your deduplication key. - Read Retries and error handling to understand logs, error labels and resending.
- Whitelist IP
103.168.55.14if your server blocks unknown connections, see IP addresses. - Want a QR code with the amount pre-filled for each order: QR payments.
Common problems
Login says wrong credentials although the password is right. Check the username (case-sensitive) and the password. New accounts can log in immediately; there is no activation queue. Still stuck: call 1900 636 648.
POST is rejected even with a valid Bearer token. The X-Client-Secret header is missing. Send it on every POST, PUT and DELETE (GET does not need it); server-side enforcement is being rolled out, so sending it now means nothing to change later.
Test delivery shows TIMEOUT. Your server did not answer within 10 seconds. Return HTTP 200 first and process the order afterwards, as in the samples above.
Signature mismatch. Usually the framework parsed the JSON and re-serialised it before signing. Sign the raw body exactly as MONA Pay sent it, byte for byte; see Webhook security.
Transferred money but no transaction shows up. Check that step 4 completed both OTPs. Without the second OTP (notification registration) ACB does not push transactions to MONA Pay.