Appearance
Postbacks
When a deposit or withdrawal reaches its final state, we POST the result to the postback URL registered for your account. You can configure separate URLs for deposits and withdrawals.
Responding to a postback
Return HTTP 200 within 30 seconds. The body can be empty — we don't parse it, and you don't need to sign it.
Return exactly 200
Other success codes such as 201 or 202 are treated as a failure and retried.
If we don't get a 200 within 30 seconds, we retry — 3 attempts in total, a few seconds apart. After the last attempt the postback is not re-sent automatically, so:
- make your handler idempotent (key on
idorinvoice_no) — you may receive the same postback more than once; - don't rely on postbacks alone — if a transaction hasn't reached a final status after a reasonable time, check with your account manager or the dashboard.
Status values
Postbacks are sent when a transaction reaches a final state: status is SUCCESS or FAILED. If a transaction's status is later corrected by our operations team, you may receive another postback for the same transaction.
Payloads
Deposit postback
Sent to your transaction postback URL.
json
{
"id": "f1413978-0a38-455d-851d-82937945bbf7",
"reference_no": "REF-20260512-000123",
"invoice_no": "INV-2026-0001",
"currency": "MYR",
"amount": "100.00",
"confirmed_at": "2026-05-12T17:48:30+08:00",
"status": "SUCCESS",
"remarks": "",
"created_at": "2026-05-12T17:45:35+08:00",
"updated_at": "2026-05-12T17:48:35+08:00"
}Withdrawal postback
Sent to your transfer-out postback URL.
json
{
"id": "9b3a1e44-7bce-4a85-9f12-d3ac82610e44",
"reference_no": "TO-20260512-000045",
"invoice_no": "WD-2026-0001",
"currency": "MYR",
"amount": "50.00",
"confirmed_at": "2026-05-12T17:50:12+08:00",
"created_at": "2026-05-12T17:48:35+08:00",
"updated_at": "2026-05-12T17:50:18+08:00",
"status": "SUCCESS",
"remarks": ""
}| Field | Type | Description |
|---|---|---|
id | string | Our transaction ID (the transaction_id from the init call) |
reference_no | string | Our reference number |
invoice_no | string | Your invoice_no from the init call |
currency | string | MYR |
amount | string | Amount, 2 decimal places |
status | string | SUCCESS or FAILED |
remarks | string | Free-text remarks (may be empty) |
confirmed_at | string | Timestamp (RFC 3339) |
created_at | string | When the transaction was created (RFC 3339) |
updated_at | string | When the transaction was last updated (RFC 3339) |
Be tolerant of extra fields
Postbacks for manually corrected transactions can include additional fields (for example status_message or prev_status) and identify the transaction as transaction_id. Ignore fields you don't recognise and match on invoice_no.
Signed vs unsigned
If you've opted in to signed postbacks, the JSON above arrives base64-encoded inside a signed envelope (below). Otherwise it arrives as the plain JSON body.
Verifying signed postbacks
Signed postbacks use exactly the same scheme you use to sign requests to us — same algorithm (RSA-SHA256, PKCS#1 v1.5), same headers, same signing string — just in the opposite direction.
| Vendor → DMC Pay request | DMC Pay → Vendor postback | |
|---|---|---|
| Who signs | You, with your private key | We, with our private key |
| Who verifies | We, with your public key | You, with our public key |
METHOD | POST | POST |
FULL_URL | Our endpoint (https://oapi.dmcpay.net/…) | Your postback URL (https://your-site.com/…) |
Wire format
http
POST <your postback URL>
Content-Type: application/json
X-Timestamp: <unix seconds when we signed>
X-Nonce: <unique per postback>
X-Signature: <base64 signature>
{
"data": "<base64(postback JSON)>"
}Steps
- Read
X-Timestamp— reject if older than ~120 seconds. - (Recommended) Remember recently-seen
X-Noncevalues for ~120 s and reject duplicates. - Build the signing string:
POST.<your registered postback URL>.<ts>.<nonce>.<data>. - Base64-decode
X-Signature. - Verify it against our public key.
- On success, base64-decode
datato get the payload. - On failure, reject the postback, log it and alert your team.
js
import crypto from 'node:crypto'
const DMC_PUBLIC_KEY = process.env.DMC_PUBLIC_KEY_PEM
const POSTBACK_URL = 'https://your-site.com/webhooks/dmc/deposit' // exactly as registered
export function verifyPostback(headers, body) {
const ts = headers['x-timestamp']
const nonce = headers['x-nonce']
if (Math.abs(Date.now() / 1000 - Number(ts)) > 120) throw new Error('stale postback')
const signingString = `POST.${POSTBACK_URL}.${ts}.${nonce}.${body.data}`
const ok = crypto.verify(
'sha256',
Buffer.from(signingString, 'utf8'),
DMC_PUBLIC_KEY,
Buffer.from(headers['x-signature'], 'base64'),
)
if (!ok) throw new Error('bad signature')
return JSON.parse(Buffer.from(body.data, 'base64').toString('utf8'))
}python
import base64, json, os, time
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
DMC_PUBLIC_KEY = serialization.load_pem_public_key(os.environ["DMC_PUBLIC_KEY_PEM"].encode())
POSTBACK_URL = "https://your-site.com/webhooks/dmc/deposit" # exactly as registered
def verify_postback(headers, body):
ts, nonce = headers["X-Timestamp"], headers["X-Nonce"]
if abs(time.time() - int(ts)) > 120:
raise ValueError("stale postback")
signing_string = f"POST.{POSTBACK_URL}.{ts}.{nonce}.{body['data']}"
DMC_PUBLIC_KEY.verify( # raises InvalidSignature on failure
base64.b64decode(headers["X-Signature"]),
signing_string.encode(),
padding.PKCS1v15(),
hashes.SHA256(),
)
return json.loads(base64.b64decode(body["data"]))Keep your postback URL stable
The URL is part of the signature. If you change your postback URL, every postback after that point is signed with the new URL — update your verifier's URL constant at the same time. If you have separate deposit and withdrawal URLs, each postback is signed with its own URL; don't mix them up in your verifier.
