Skip to content

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>
PartValue
METHODUppercase HTTP method, e.g. POST
FULL_URLThe complete URL you POST to: scheme + host + path + query (if any)
X-TimestampExactly the value of the X-Timestamp header
X-NonceExactly the value of the X-Nonce header
body.dataExactly 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.

RuleExample
Scheme is lowercasehttps://, not HTTPS://
Host is lowercasehttps://oapi.dmcpay.net, not https://OAPI.dmcpay.net
Default port (443/80) is omittedhttps://oapi.dmcpay.net/…, not https://oapi.dmcpay.net:443/…
Path matches what you POST toPOST 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 signedFragments never go on the wire

Use the right URL per environment ​

EnvironmentURL to sign and POST
Production deposithttps://oapi.dmcpay.net/api/v2/transaction/init
Production withdrawalhttps://oapi.dmcpay.net/api/v2/transfer-out/init
Staging deposithttps://staging-oapi.dmcpay.net/api/v2/transaction/init
Staging withdrawalhttps://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 ​

ParameterValue
Signature algorithmRSA
PaddingPKCS#1 v1.5 (not PSS)
HashSHA-256
Key size≥ 2048 bits
X-Signature encodingStandard base64 (with padding)
data encodingStandard base64 (with padding)
X-Timestamp formatUnix seconds, decimal ASCII (e.g. 1747042800)
X-Nonce format16–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.

Need help? Contact your DMC Pay account manager.