Merchant API Reference

Accept Malaysian DuitNow / QR payments and manage settlements programmatically.


FormatJSON
AuthenticationX-API-Key header
CurrencyMalaysian Ringgit (MYR) — amounts in sen (integer)
Content-Typeapplication/json on POST requests

Authentication

Every API request requires your merchant API key in the X-API-Key header:

HTTP 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.

Keep your key secret. Never expose your API key in client-side JavaScript or mobile app code. All API calls must originate from your server.

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.

Node.js
const GATEWAY_API = process.env.PAYMENTSJ_API_URL ?? 'https://api.yourgateway.com';
PHP
$gateway_api = getenv('PAYMENTSJ_API_URL') ?: 'https://api.yourgateway.com';

Create Payment

POST/api/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

FieldTypeRequiredDescription
amountCentsintegerRequiredAmount in Malaysian sen. Range: 1 – 5,000,000.
RM 1.00 = 100  ·  RM 50.00 = 5000
referencestringOptionalYour order/reference ID. Used as an idempotency key — duplicate reference values return 409 instead of creating a second job. Max 255 chars.
callbackUrlstringOptionalHTTPS URL for server-to-server payment notification. Falls back to the default webhook URL set in your portal settings.
returnUrlstringOptionalHTTPS URL to redirect the customer to after successful payment.
failUrlstringOptionalHTTPS URL to redirect the customer to after a payment expires or is cancelled.
Amount is in sen, not ringgit. Multiply your ringgit amount by 100 to get sen. Sending "amountCents": 50 creates a payment for RM 0.50, not RM 50.00.

Example request

Node.js / fetch
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);
PHP / cURL
$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
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

JSON
{
  "jobId":  "550e8400-e29b-41d4-a716-446655440000",
  "status": "pending",
  "payUrl": "https://pay.yourgateway.com/pay/550e8400-.../select"
}

Error responses

StatusReason
400amountCents out of range, invalid URL format, or URL domain not in allowed list
401Missing or invalid X-API-Key
403Merchant account is suspended
409Duplicate reference — body includes existing job details (see Idempotency)
429Daily transaction volume limit exceeded for your account

Check Payment Status

GET/api/payment/:jobId/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

JSON
{
  "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"
}
Only your own jobs are accessible. Requesting a jobId that belongs to another merchant returns 404.

Job Lifecycle

pending processing qr_ready
expired
StatusMeaning
pendingJob created, waiting for a device to claim it
processingDevice claimed the job, generating QR code
qr_readyQR code is live on the payment page — customer can scan
paidPayment confirmed by bank notification. Terminal state.
expiredQR 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

HTTP
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"
}
Your server must respond with any 2xx status within 15 seconds. If it doesn't, the delivery is marked failed and retried on a schedule. See Retry schedule.

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.

Never skip signature verification. Without it, anyone on the internet can send a fake "paid" callback to your server and credit an order without paying.
Node.js (Express)
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');
});
PHP
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';
Python (Flask)
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.

AttemptDelay after previous failure
1st retry30 seconds
2nd retry2 minutes
3rd retry10 minutes
ExhaustedNo further automatic retries — visible in your transaction detail in the merchant portal. Contact support if needed.
An admin can manually trigger an immediate retry from the admin panel. Your server will receive the same webhook payload again — make your handler idempotent by checking if you've already processed the 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:

Example redirect URL
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.

Node.js — server-side verification
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');
Do not rely on the redirect alone to fulfil an order. The redirect can be missed (browser closed, network drop). Always use the webhook as your primary signal and treat the redirect as a UX convenience only.

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

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

Node.js
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.

Why this matters. Without allowed domains, a compromised API key could be used to create payments with the attacker's own callback and redirect URLs — receiving payment notifications and redirecting your customers to a malicious site.

Format

Comma-separated bare hostnames. Subdomains are automatically included.

Example
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

GET/api/merchant/settlement/balance

Returns your current available balance — total paid revenue minus fees and any pending or approved settlement amounts already reserved.

Response 200 OK

JSON
{
  "availableCents": 250000,
  "availableRm":    "2500.00",
  "minAmountCents": 10000     // minimum payout request = RM 100.00
}

List Payout Accounts

GET/api/merchant/settlement/bank-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

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

POST/api/merchant/settlement-requests

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

FieldTypeRequiredDescription
amountCentsintegerRequiredAmount to withdraw in sen. Minimum: 10000 (RM 100.00)
payoutAccountIdstringRequiredUUID of a verified payout account from List payout accounts
notestringOptionalInternal note (e.g. "Weekly settlement W21")

Response 201 Created

JSON
{
  "settlementId": "STL-2026-0012",
  "amountCents":  200000,
  "status":       "pending"
}

Error responses

StatusReason
400amountCents below minimum, missing payoutAccountId, or account not verified
422Requested amount exceeds available balance
429Rate limit — max 10 settlement requests per minute

Error Codes

All error responses share a consistent shape:

JSON
{ "error": "Human-readable description of what went wrong" }
StatusMeaning
400Bad request — invalid parameters, URL validation failure, or domain not allowed
401Authentication failure — missing or invalid X-API-Key
403Account suspended — contact support
404Resource not found (or belongs to another merchant — both return 404)
409Idempotency conflict — duplicate reference
422Business rule violation — e.g. insufficient balance for settlement
429Rate limit or daily volume limit exceeded
5xxServer error — retry with exponential backoff; contact support if persistent

Quick Reference

Create payment
POST
/api/payment
Check status
GET
/api/payment/:jobId/status
Settlement balance
GET
/api/merchant/settlement/balance
Payout accounts
GET
/api/merchant/settlement/bank-accounts
Request payout
POST
/api/merchant/settlement-requests

Environment variables your server needs

VariableValue
PAYMENTSJ_API_URLAPI base URL — provided by your gateway operator
PAYMENTSJ_API_KEYYour merchant API key (mk_live_…)
PAYMENTSJ_WEBHOOK_SECRETWebhook signing secret — from portal Settings → Webhook signing secret