Merchant API Reference
Accept Malaysian DuitNow / QR payments and manage settlements programmatically.
| Format | JSON |
| Authentication | X-API-Key header |
| Currency | Malaysian Ringgit (MYR) — amounts in sen (integer) |
| Content-Type | application/json on POST requests |
Authentication
Every API request requires your merchant API key in the X-API-Key header:
X-API-Key: mk_live_your_key_here
API keys are managed in your merchant portal at Settings → API Key. You can rotate your key at any time — the old key stops working immediately upon rotation.
Base URL
Read the API base URL from an environment variable in your application — do not hardcode it. This allows you to switch to a backup URL without a code deployment if needed.
const GATEWAY_API = process.env.PAYMENTSJ_API_URL ?? 'https://api.yourgateway.com';
$gateway_api = getenv('PAYMENTSJ_API_URL') ?: 'https://api.yourgateway.com';
Create Payment
Creates a new payment job and returns a hosted payment URL. Redirect your customer's browser to payUrl — they will select their wallet (DuitNow QR / Touch 'n Go / Boost) and complete payment on the hosted page.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
amountCents | integer | Required | Amount in Malaysian sen. Range: 1 – 5,000,000. RM 1.00 = 100 · RM 50.00 = 5000 |
reference | string | Optional | Your order/reference ID. Used as an idempotency key — duplicate reference values return 409 instead of creating a second job. Max 255 chars. |
callbackUrl | string | Optional | HTTPS URL for server-to-server payment notification. Falls back to the default webhook URL set in your portal settings. |
returnUrl | string | Optional | HTTPS URL to redirect the customer to after successful payment. |
failUrl | string | Optional | HTTPS URL to redirect the customer to after a payment expires or is cancelled. |
"amountCents": 50 creates a payment for RM 0.50, not RM 50.00.
Example request
const res = await fetch(`${GATEWAY_API}/api/payment`, {
method: 'POST',
headers: {
'X-API-Key': process.env.PAYMENTSJ_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amountCents: 5000, // RM 50.00
reference: 'ORDER-2026-001',
callbackUrl: 'https://merchant.com/webhooks/pay',
returnUrl: 'https://merchant.com/order/2026-001/success',
failUrl: 'https://merchant.com/order/2026-001/failed',
}),
});
const { jobId, payUrl } = await res.json();
// Redirect customer to payUrl
res.redirect(payUrl);
$payload = json_encode([
'amountCents' => 5000, // RM 50.00
'reference' => 'ORDER-2026-001',
'callbackUrl' => 'https://merchant.com/webhooks/pay',
'returnUrl' => 'https://merchant.com/order/2026-001/success',
'failUrl' => 'https://merchant.com/order/2026-001/failed',
]);
$ch = curl_init(getenv('PAYMENTSJ_API_URL') . '/api/payment');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'X-API-Key: ' . getenv('PAYMENTSJ_API_KEY'),
'Content-Type: application/json',
],
]);
$response = json_decode(curl_exec($ch), true);
// Redirect customer
header('Location: ' . $response['payUrl']);
exit();
curl -X POST https://api.yourgateway.com/api/payment \
-H "X-API-Key: mk_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"amountCents": 5000,
"reference": "ORDER-2026-001",
"callbackUrl": "https://merchant.com/webhooks/pay",
"returnUrl": "https://merchant.com/order/2026-001/success",
"failUrl": "https://merchant.com/order/2026-001/failed"
}'
Response 201 Created
{
"jobId": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
"payUrl": "https://pay.yourgateway.com/pay/550e8400-.../select"
}
Error responses
| Status | Reason |
|---|---|
| 400 | amountCents out of range, invalid URL format, or URL domain not in allowed list |
| 401 | Missing or invalid X-API-Key |
| 403 | Merchant account is suspended |
| 409 | Duplicate reference — body includes existing job details (see Idempotency) |
| 429 | Daily transaction volume limit exceeded for your account |
Check Payment Status
Returns the current state of a payment job. Use this as a fallback to the webhook — poll occasionally if you haven't received a callback, not on every page load.
Response 200 OK
{
"jobId": "550e8400-...",
"status": "paid",
"amountCents": 5000,
"reference": "ORDER-2026-001",
"amountRm": "50.00",
"mbbReference":"MBB-7382910", // bank transaction reference
"qrUrl": "https://pay.yourgateway.com/uploads/qr_...png",
"cancelReason":null,
"createdAt": "2026-05-25T08:00:00.000Z",
"updatedAt": "2026-05-25T08:01:30.000Z"
}
jobId that belongs to another merchant returns 404.
Job Lifecycle
| Status | Meaning |
|---|---|
pending | Job created, waiting for a device to claim it |
processing | Device claimed the job, generating QR code |
qr_ready | QR code is live on the payment page — customer can scan |
paid | Payment confirmed by bank notification. Terminal state. |
expired | QR timed out, customer cancelled, or device error. Terminal state. |
Webhooks are fired on transition to paid and expired only.
Webhook Delivery
When a job reaches a terminal state (paid or expired), we send a signed POST request to your callbackUrl. Configure a default webhook URL for all payments in your portal at Settings → Webhook URL, or supply a per-payment callbackUrl on each request.
Request your server receives
POST /webhooks/paymentsj HTTP/1.1
Host: merchant.com
Content-Type: application/json
X-PaymentsJ-Signature: sha256=a7f3d9e2b1c4...
User-Agent: PaymentsJ-Callback/1.0
{
"jobId": "550e8400-e29b-41d4-a716-446655440000",
"status": "paid",
"amountRm": "50.00",
"referenceNo":"MBB-7382910"
}
Verify Webhook Signature
Always verify the X-PaymentsJ-Signature header before trusting any callback. Your per-merchant webhook secret is in the portal at Settings → Webhook signing secret. Generate or rotate it there.
const crypto = require('crypto');
function verifySignature(rawBody, sigHeader, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody) // raw bytes — before JSON.parse
.digest('hex');
if (expected.length !== sigHeader.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(sigHeader),
);
}
// Express route — use express.raw() to capture raw body
app.post('/webhooks/paymentsj', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-paymentsj-signature'];
const secret = process.env.PAYMENTSJ_WEBHOOK_SECRET;
if (!verifySignature(req.body, sig, secret)) {
return res.status(401).send('Invalid signature');
}
const { jobId, status, amountRm, referenceNo } = JSON.parse(req.body);
if (status === 'paid') {
// fulfil order, send confirmation email, etc.
}
res.status(200).send('OK');
});
function verifySignature(string $rawBody, string $sigHeader, string $secret): bool
{
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
return hash_equals($expected, $sigHeader);
}
$rawBody = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_PAYMENTSJ_SIGNATURE'] ?? '';
$secret = getenv('PAYMENTSJ_WEBHOOK_SECRET');
if (!verifySignature($rawBody, $sig, $secret)) {
http_response_code(401);
exit('Invalid signature');
}
$payload = json_decode($rawBody, true);
if ($payload['status'] === 'paid') {
// fulfil order
}
echo 'OK';
import hmac, hashlib, os
from flask import request, abort
def verify_signature(raw_body: bytes, sig_header: str, secret: str) -> bool:
expected = 'sha256=' + hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, sig_header)
@app.route('/webhooks/paymentsj', methods=['POST'])
def paymentsj_webhook():
sig = request.headers.get('X-PaymentsJ-Signature', '')
secret = os.environ['PAYMENTSJ_WEBHOOK_SECRET']
if not verify_signature(request.data, sig, secret):
abort(401)
payload = request.get_json()
if payload['status'] == 'paid':
pass # fulfil order
return 'OK', 200
Retry Schedule
Failed webhook deliveries are retried automatically. Retries survive server restarts — they are stored in the database, not in memory.
| Attempt | Delay after previous failure |
|---|---|
| 1st retry | 30 seconds |
| 2nd retry | 2 minutes |
| 3rd retry | 10 minutes |
| Exhausted | No further automatic retries — visible in your transaction detail in the merchant portal. Contact support if needed. |
jobId.
Browser Redirect Verification
When a customer completes payment (or a payment expires), they are redirected to your returnUrl or failUrl with signed query parameters appended:
https://merchant.com/order/success
?jobId=550e8400-e29b-41d4-a716-446655440000
&status=paid
&sig=a7f3d9e2b1c4...
&exp=1748145600
Verify these parameters server-side before showing a success page. The signature uses the same webhook secret and expires 5 minutes after generation — replay attacks are blocked.
const crypto = require('crypto');
function verifyRedirect(jobId, status, sig, exp, secret) {
if (Date.now() / 1000 > Number(exp)) return false; // expired
const expected = crypto
.createHmac('sha256', secret)
.update(`${jobId}:${status}:${exp}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(sig),
);
}
// In your success page handler:
const { jobId, status, sig, exp } = req.query;
const valid = verifyRedirect(jobId, status, sig, exp, process.env.PAYMENTSJ_WEBHOOK_SECRET);
if (!valid) return res.status(400).send('Invalid or expired redirect');
Idempotency
Use the reference field as an idempotency key. If your server retries POST /api/payment with the same reference, you receive 409 with the existing job — no duplicate payment is created.
409 response body
{
"error": "A payment with this reference already exists for your account",
"jobId": "550e8400-...",
"status": "paid",
"amountCents": 5000,
"createdAt": "2026-05-25T08:00:00.000Z"
}
Safe retry pattern
async function createPayment(orderId, amountCents) {
const res = await fetch(`${GATEWAY_API}/api/payment`, {
method: 'POST',
headers: { 'X-API-Key': API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ amountCents, reference: orderId }),
});
if (res.status === 409) {
const existing = await res.json();
return existing; // { jobId, status, amountCents, createdAt }
}
if (!res.ok) throw new Error(`Payment creation failed: ${res.status}`);
return res.json(); // { jobId, status, payUrl }
}
Allowed Domains
Configure your allowed domains in Merchant Portal → Settings → Allowed Domains to lock down where callbacks and redirects can be sent. Once set, any payment request specifying a URL outside your allowed list is rejected with 400.
Format
Comma-separated bare hostnames. Subdomains are automatically included.
merchant.com,api.merchant.com,checkout.merchant.com
With this config, https://merchant.com/webhooks/pay is accepted but https://evil.com/steal is rejected with 400.
Get Settlement Balance
Returns your current available balance — total paid revenue minus fees and any pending or approved settlement amounts already reserved.
Response 200 OK
{
"availableCents": 250000,
"availableRm": "2500.00",
"minAmountCents": 10000 // minimum payout request = RM 100.00
}
List Payout Accounts
Returns the verified bank accounts registered for settlement payouts. Add or manage accounts in the merchant portal under Settings → Payout accounts.
Response 200 OK
{
"accounts": [
{
"id": "a1b2c3d4-...",
"bankName": "Maybank",
"accountMasked": "****1234",
"isDefault": true
}
]
}
Only verified accounts appear here. An account must be verified by the admin team before it can receive payouts.
Request Payout
Submit a payout request. The request enters a review queue — the admin team approves and processes it manually. You will receive an email confirmation when the transfer is made.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
amountCents | integer | Required | Amount to withdraw in sen. Minimum: 10000 (RM 100.00) |
payoutAccountId | string | Required | UUID of a verified payout account from List payout accounts |
note | string | Optional | Internal note (e.g. "Weekly settlement W21") |
Response 201 Created
{
"settlementId": "STL-2026-0012",
"amountCents": 200000,
"status": "pending"
}
Error responses
| Status | Reason |
|---|---|
| 400 | amountCents below minimum, missing payoutAccountId, or account not verified |
| 422 | Requested amount exceeds available balance |
| 429 | Rate limit — max 10 settlement requests per minute |
Error Codes
All error responses share a consistent shape:
{ "error": "Human-readable description of what went wrong" }
| Status | Meaning |
|---|---|
| 400 | Bad request — invalid parameters, URL validation failure, or domain not allowed |
| 401 | Authentication failure — missing or invalid X-API-Key |
| 403 | Account suspended — contact support |
| 404 | Resource not found (or belongs to another merchant — both return 404) |
| 409 | Idempotency conflict — duplicate reference |
| 422 | Business rule violation — e.g. insufficient balance for settlement |
| 429 | Rate limit or daily volume limit exceeded |
| 5xx | Server error — retry with exponential backoff; contact support if persistent |
Quick Reference
/api/payment/api/payment/:jobId/status/api/merchant/settlement/balance/api/merchant/settlement/bank-accounts/api/merchant/settlement-requestsEnvironment variables your server needs
| Variable | Value |
|---|---|
PAYMENTSJ_API_URL | API base URL — provided by your gateway operator |
PAYMENTSJ_API_KEY | Your merchant API key (mk_live_…) |
PAYMENTSJ_WEBHOOK_SECRET | Webhook signing secret — from portal Settings → Webhook signing secret |