Appearance
Request signing
Signed endpoints (/api/v2/transaction/init, /api/v2/transfer-out/init) require an RSA signature on every request, on top of the access token.
Wire format
http
POST https://oapi.dmcpay.net/api/v2/transaction/init
Content-Type: application/json
Authorization: Bearer <access token>
X-Timestamp: <unix seconds at sign time, decimal ASCII>
X-Nonce: <16–64 chars from [A-Za-z0-9_-]>
X-Signature: <base64( RSA-SHA256-PKCS1v1.5(privKey, signing_string) )>
{
"data": "<base64(original_json_payload)>"
}Your actual request payload (e.g. the deposit fields) is JSON-encoded, then base64-encoded into the data field. The envelope carries nothing else.
The signing string
The signature is computed over a canonical string built by joining five parts with literal dots:
txt
<METHOD>.<FULL_URL>.<X-Timestamp>.<X-Nonce>.<body.data>| Part | Value |
|---|---|
METHOD | Uppercase HTTP method, e.g. POST |
FULL_URL | The complete URL you POST to: scheme + host + path + query (if any) |
X-Timestamp | Exactly the value of the X-Timestamp header |
X-Nonce | Exactly the value of the X-Nonce header |
body.data | Exactly the value of the data field in the JSON body (the raw base64 string) |
A real example:
txt
POST.https://oapi.dmcpay.net/api/v2/transaction/init.1747042800.q7Ks3pXm9LaNvBwR2tF8Yu.eyJhbW91bnQiOiIxMDAuMDAi...No spaces. No newlines. Sign the UTF-8 bytes of that literal string.
Complete examples
Each example builds the envelope, signs it and sends it. Swap in your own payload, token and key path.
js
import crypto from 'node:crypto'
import fs from 'node:fs'
const BASE_URL = process.env.DMC_BASE_URL // e.g. https://staging-oapi.dmcpay.net
const privateKey = fs.readFileSync('vendor_private.pem', 'utf8')
async function signedPost(path, payload, accessToken) {
const url = BASE_URL + path
const data = Buffer.from(JSON.stringify(payload)).toString('base64')
const ts = String(Math.floor(Date.now() / 1000))
const nonce = crypto.randomBytes(16).toString('base64url')
const signingString = `POST.${url}.${ts}.${nonce}.${data}`
const signature = crypto.sign('sha256', Buffer.from(signingString, 'utf8'), privateKey).toString('base64')
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
'X-Timestamp': ts,
'X-Nonce': nonce,
'X-Signature': signature,
},
body: JSON.stringify({ data }),
})
return res.json()
}
await signedPost('/api/v2/transaction/init', {
amount: '10.00', currency: 'MYR', gateway: 'MAYBANK', user_id: 'player-123', invoice_no: 'INV-0001',
}, accessToken)python
import base64, json, os, secrets, time
import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
BASE_URL = os.environ["DMC_BASE_URL"] # e.g. https://staging-oapi.dmcpay.net
with open("vendor_private.pem", "rb") as f:
PRIVATE_KEY = serialization.load_pem_private_key(f.read(), password=None)
def signed_post(path, payload, access_token):
url = BASE_URL + path
data = base64.b64encode(json.dumps(payload).encode()).decode()
ts = str(int(time.time()))
nonce = base64.urlsafe_b64encode(secrets.token_bytes(16)).rstrip(b"=").decode()
signing_string = f"POST.{url}.{ts}.{nonce}.{data}"
signature = base64.b64encode(
PRIVATE_KEY.sign(signing_string.encode(), padding.PKCS1v15(), hashes.SHA256())
).decode()
return requests.post(url, json={"data": data}, headers={
"Authorization": f"Bearer {access_token}",
"X-Timestamp": ts,
"X-Nonce": nonce,
"X-Signature": signature,
}, timeout=30).json()php
<?php
$baseUrl = getenv('DMC_BASE_URL'); // e.g. https://staging-oapi.dmcpay.net
$privateKey = openssl_pkey_get_private(file_get_contents('vendor_private.pem'));
function signedPost(string $path, array $payload, string $accessToken): array {
global $baseUrl, $privateKey;
$url = $baseUrl . $path;
$data = base64_encode(json_encode($payload));
$ts = (string) time();
$nonce = rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '=');
$signingString = "POST.{$url}.{$ts}.{$nonce}.{$data}";
openssl_sign($signingString, $sig, $privateKey, OPENSSL_ALGO_SHA256);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => json_encode(['data' => $data]),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
"Authorization: Bearer {$accessToken}",
"X-Timestamp: {$ts}",
"X-Nonce: {$nonce}",
'X-Signature: ' . base64_encode($sig),
],
]);
$res = curl_exec($ch);
curl_close($ch);
return json_decode($res, true);
}go
package dmc
import (
"bytes"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"strconv"
"time"
)
func SignedPost(baseURL, path string, payload any, accessToken string, key *rsa.PrivateKey) (*http.Response, error) {
url := baseURL + path
inner, _ := json.Marshal(payload)
data := base64.StdEncoding.EncodeToString(inner)
ts := strconv.FormatInt(time.Now().Unix(), 10)
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return nil, err
}
nonce := base64.RawURLEncoding.EncodeToString(b[:])
digest := sha256.Sum256([]byte("POST." + url + "." + ts + "." + nonce + "." + data))
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
return nil, err
}
body, _ := json.Marshal(map[string]string{"data": data})
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("X-Timestamp", ts)
req.Header.Set("X-Nonce", nonce)
req.Header.Set("X-Signature", base64.StdEncoding.EncodeToString(sig))
return http.DefaultClient.Do(req)
}URL formatting rules
The signature breaks if your client and our server build different URL strings. The simple rule: sign the exact URL you're going to POST to.
| Rule | Example |
|---|---|
| Scheme is lowercase | https://, not HTTPS:// |
| Host is lowercase | https://oapi.dmcpay.net, not https://OAPI.dmcpay.net |
| Default port (443/80) is omitted | https://oapi.dmcpay.net/…, not https://oapi.dmcpay.net:443/… |
| Path matches what you POST to | POST to /api/v2/transaction/init → sign that. Trailing / → sign it with the / |
Empty query string → omit the ? | Sign /path, not /path? |
Non-empty query → include ? and params exactly as sent | /path?a=1&b=2 — don't reorder, don't normalise |
URL fragments (#…) are not signed | Fragments never go on the wire |
Use the right URL per environment
| Environment | URL to sign and POST |
|---|---|
| Production deposit | https://oapi.dmcpay.net/api/v2/transaction/init |
| Production withdrawal | https://oapi.dmcpay.net/api/v2/transfer-out/init |
| Staging deposit | https://staging-oapi.dmcpay.net/api/v2/transaction/init |
| Staging withdrawal | https://staging-oapi.dmcpay.net/api/v2/transfer-out/init |
WARNING
If you sign for production but POST to staging (or vice versa) you'll get signature.verify.failed. Use one config value for both the signing string and your HTTP client.
X-Timestamp
Unix seconds, current time, as a decimal string. Not milliseconds. Not ISO 8601.
js
const ts = String(Math.floor(Date.now() / 1000)) // "1747042800"python
ts = str(int(time.time())) # "1747042800"php
$ts = (string) time(); // "1747042800"go
ts := strconv.FormatInt(time.Now().Unix(), 10) // "1747042800"Your clock must be within the accepted window of ours:
- more than 60 s behind →
signature.timestamp.expired - more than 5 s ahead →
signature.timestamp.future
Use NTP. Most cloud VMs sync automatically — double-check bare-metal and container hosts.
X-Nonce
A unique string, 16–64 characters from [A-Za-z0-9_-] (the URL-safe base64 alphabet, no padding). Recommended: 16 random bytes as base64url without padding → 22 characters.
js
const nonce = require('crypto').randomBytes(16).toString('base64url')python
nonce = base64.urlsafe_b64encode(secrets.token_bytes(16)).rstrip(b"=").decode()php
$nonce = rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '=');go
var b [16]byte
_, _ = rand.Read(b[:])
nonce := base64.RawURLEncoding.EncodeToString(b[:])Each nonce can be used once. We remember used nonces per vendor for about 95 seconds; reusing one in that window returns signature.nonce.replayed (HTTP 401). Generate a fresh nonce for every request — including retries.
Algorithm summary
| Parameter | Value |
|---|---|
| Signature algorithm | RSA |
| Padding | PKCS#1 v1.5 (not PSS) |
| Hash | SHA-256 |
| Key size | ≥ 2048 bits |
X-Signature encoding | Standard base64 (with padding) |
data encoding | Standard base64 (with padding) |
X-Timestamp format | Unix seconds, decimal ASCII (e.g. 1747042800) |
X-Nonce format | 16–64 chars from [A-Za-z0-9_-] |
| Signing string | <METHOD>.<FULL_URL>.<X-Timestamp>.<X-Nonce>.<body.data> |
| Clock-skew tolerance | −60 s to +5 s relative to server time |
Something not working? See the signature error codes.
