Skip to content

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 id or invoice_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": ""
}
FieldTypeDescription
idstringOur transaction ID (the transaction_id from the init call)
reference_nostringOur reference number
invoice_nostringYour invoice_no from the init call
currencystringMYR
amountstringAmount, 2 decimal places
statusstringSUCCESS or FAILED
remarksstringFree-text remarks (may be empty)
confirmed_atstringTimestamp (RFC 3339)
created_atstringWhen the transaction was created (RFC 3339)
updated_atstringWhen 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 requestDMC Pay → Vendor postback
Who signsYou, with your private keyWe, with our private key
Who verifiesWe, with your public keyYou, with our public key
METHODPOSTPOST
FULL_URLOur 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 ​

  1. Read X-Timestamp — reject if older than ~120 seconds.
  2. (Recommended) Remember recently-seen X-Nonce values for ~120 s and reject duplicates.
  3. Build the signing string: POST.<your registered postback URL>.<ts>.<nonce>.<data>.
  4. Base64-decode X-Signature.
  5. Verify it against our public key.
  6. On success, base64-decode data to get the payload.
  7. 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.

Need help? Contact your DMC Pay account manager.