Skip to content

OpenAPI v1 Merchant Integration Document ​

Directory ​

  1. Access Preparation

  2. General Agreement

  3. Encryption, decryption, and signing

  4. Interface list

  5. Asynchronous callback (Webhook)

  6. Error code

  7. Enumeration description

  8. Best practices

  9. Java Demo


1. Access Preparation ​

After the merchant completes the development settings in the management backend, they will receive the following information

Configuration itemPurpose
clientIdInclude Authorization in the request header to identify the merchant's identity
symmetricKey32-bit AES symmetric key, encryption/decryption request/response body
serverPublicKeyThe merchant's RSA public key (PEM) is used by the platform for signature verification. The merchant retains the corresponding private key for signing
depositCallbackURL for asynchronous notification of collection (recharge) results
withdrawCallbackAsynchronous notification URL for payment (withdrawal) results

Environment address (example, subject to actual deployment):

EnvironmentBase URL
SIThttps://{sit_host}/openapi/v1
Productionhttps://{prod_host}/openapi/v1

2. General Agreement ​

2.1 Public Request Header ​

All OpenAPI interfaces use POST and must carry the following headers:

HeaderRequiredInstructions
AuthorizationYesMerchant clientId (pure numerical string)
X-Nonce-StrYesRandom string, UUID is recommended, unique for each request to prevent replay
X-TimestampYesMillisecond timestamp (string), recommended to be within ±5 minutes of the server time difference
X-SignatureYesFormat: sha256 {Base64 signature}
Content-TypeYesapplication/json

2.2 Public HTTP request body (outer layer) ​

JSON
{
  "data": "Business plaintext after AES encryption (HEX lowercase string)"
}

2.3 Public HTTP response body (outer layer) ​

Success:

JSON
{
  "code": 200,
  "encryptedData": "Business plain text (HEX lowercase string) encrypted with AES",
  "message": "Success"
}

Failure:

JSON
{
  "code": 400,
  "message": "Missing required parameters"
}

When failing, there is usually no encryptedData field, please handle according to code + message .

2.4 Business encryption and decryption process ​

Plaintext
Plain JSON → UTF-8 ByteDance → AES-256-CBC encryption → HEX output (request data / response encryptedData)

Decryption is the reverse operation of the above process. The algorithm details can be found in Section 3.


3. Encryption, Decryption, and Signature ​

3.1 AES-256-CBC ​

ItemRules
AlgorithmAES/CBC/PKCS5Padding
KeysymmetricKey UTF-8 ByteDance's previous 32 ByteDance
IVKey's previous **16 **ByteDance
Cipher codeBinary cipher conversion **lowercase HEX **string

3.2 RSA-SHA256 Signature (Merchant Request Signature) ​

To be signed string (original text):

Plaintext
{clientId}\n{nonceStr}\n{timestamp}\n{encryptedData}

Explanation:encryptedData is the HEX ciphertext of the data field in the request body (not decrypted when signing).

Signature steps:

  1. Use merchant RSA **private key **(PKCS #8 PEM) to SHA256withRSA signature

  2. Signature result Base64 encoding

  3. Insert into Header: X-Signature: sha256 {Base64 signature}

The platform uses the serverPublicKey signature verification configured by merchants in the backend. The public key must be configured in the production environment; if not, the platform may skip the signature verification (test only).


4. Interface List ​

4.1 Merchants place an order (collect on behalf) ​

POST /openapi/v1/createPayment

Business clear text (after AES decryption) ​

JSON
{
  "order": {
    "id": "MCH202603170001",
    "title": "商品购买",
    "amount": "88.50",
    "currencyType": "MYR",
    "additionalData": ""
  },
  "customer": {
    "name": "John Doe",
    "phone": "60123456789",
    "email": "john@example.com"
  },
  "method": "CIMB_MY",
  "callbackUrl": "https://merchant.example.com/notify/deposit"
}
FieldTypeRequiredInstructions
order.idStringYesMerchant order number, globally unique, corresponding to the platform mchtOrderNo
order.titleStringNoOrder title/description
order.amountStringYesOrder amount, such as "88.50", minimum 0.01
order.currencyTypeStringNoCurrency, default MYR
order.additionalDataStringNoAdditional data
customer.nameStringNoCustomer's name
customer.phoneStringNoCustomer's mobile phone number
customer.emailStringNoCustomer email
methodStringNoPayment method, controls the checkout page display scope. See details below.
callbackUrlStringNoCallback URL for the result of this order only. If omitted, the deposit callback URL configured for the merchant is used (see section 5.1).

Business response in clear text (after AES decryption) encryptedData ​

JSON
{
  "data": {
    "paymentUrl": "https://checkout.example.com/...",
    "transactionId": "12026031700000001"
  }
}
FieldInstructions
paymentUrlGuide users to complete the payment through the checkout page/payment redirect link
transactionIdPlatform order number, please persist for subsequent inquiries and reconciliation

Typical failure message ​

messageThe reason
Missing order.idThe merchant order number has not been sent
The merchant has not set up a payment collection channelMerchants have not bound their payment channels
Failed to create an order through the channel: ...Downstream channels reject or misconfigure
Payment method must not be specified for this merchantMerchant is configured with NONE mode, method is not allowed
Only payment category (e.g. FPX, Ewallet, QR) is allowedMerchant is configured with CATEGORY_ONLY mode, only category is allowed
Merchant rate not configured for platform and methodMerchant has no rate configured for this payment method

method Field Description ​

The method field controls the display scope of payment methods on the checkout page. It supports the following three types of values:

Value TypeExampleEffect
Not passed / EmptyOmit the fieldDisplay all available payment methods configured for the merchant
Payment Category"FPX", "Ewallet", "QR", "CreditCard"Only show payment methods under that category
Specific Method Code"CIMB_MY", "MAE_MY"Only show that specific method, redirect to payment directly

See 7.1 Collection Payment Method for available method codes.

deposit_payment_method_mode Restriction ​

The platform can configure deposit_payment_method_mode for each merchant to restrict what values are allowed in the method field:

ModeDescriptionAllowed method ValuesError on Violation
Not configured (default)No restrictionEmpty / Category / Specific method are all allowed—
NONEPayment method is prohibitedOnly omit or empty string is allowed400 Payment method must not be specified for this merchant
CATEGORY_ONLYOnly payment category is allowedEmpty / FPX / Ewallet / QR / CreditCard400 Only payment category (e.g. FPX, Ewallet, QR) is allowed

To change the mode, contact operations to modify the merchant configuration in the admin panel.


4.2 Check the collection status ​

POST /openapi/v1/getTransactionStatusById

Business clear text (after AES decryption) ​

JSON
{
  "transactionId": "12026031700000001",
  "type": 1
}

or

JSON
{
  "merchantOrderId": "MCH202603170001",
  "type": 1
}
FieldTypeRequiredInstructions
transactionIdStringOne of twoPlatform order number (transactionId returned from create)
merchantOrderIdStringOne of twoMerchant order number (corresponds to order\.id in the request)
typeIntegerNoFixed pass 1 for deposit query (reserved field)

transactionId and merchantOrderId are mutually exclusive; if both are provided, transactionId takes priority.

Business response in clear text ​

JSON
{
  "data": {
    "status": "SUCCESS",
    "transaction": {
      "id": "12026031700000001",
      "amountPaid": 88.50,
      "confirmedAt": "2026-03-17 10:30:00",
      "paymentMethod": "CIMB_MY",
      "commissionFee": 1.20
    },
    "order": {
      "id": "MCH202603170001",
      "amount": 88.50,
      "currencyType": "MYR"
    },
    "customer": {
      "name": "John Doe",
      "phone": "60123456789",
      "email": "john@example.com"
    }
  }
}
FieldInstructions
data.statusOrder status, see 7.3 Order Status
transaction.idPlatform order number
transaction.amountPaidAmount actually paid
transaction.confirmedAtPayment confirmation time
transaction.paymentMethodActual payment method
transaction.commissionFeeHandling fee
order.idMerchant order number
customer.*The customer information provided when placing an order

4.3 Initiate withdrawal (on behalf of payment) ​

POST /openapi/v1/withdrawRequest

Business clear text (after AES decryption) ​

JSON
{
  "order": {
    "id": "WD202603170001",
    "amount": "100.00",
    "currencyType": "MYR"
  },
  "recipient": {
    "name": "James Lee",
    "phone": "60123456789",
    "email": "james@example.com",
    "methodType": "CIBBMYKL",
    "methodValue": "8044591766",
    "methodRef": ""
  },
  "callbackUrl": "https://merchant.example.com/notify/withdraw"
}
FieldTypeRequiredInstructions
order.idStringYesMerchant withdrawal slip number, unique
order.amountStringYesWithdrawal amount (yuan), such as "100.00"; the platform internally converts to points (×100)
order.currencyTypeStringNoCurrency, default MYR
recipient.nameStringYesName of the recipient
recipient.phoneStringNoReceiver's mobile phone
recipient.emailStringYesReceiver's email
recipient.methodTypeStringYesPayment bank logo, see 7.2 bank code for payment
recipient.methodValueStringYesBank account
recipient.methodRefStringNoAdditional reference information
callbackUrlStringNoCallback URL for the result of this withdrawal only. If omitted, the withdrawal callback URL configured for the merchant is used (see section 5.2).

Business response in clear text ​

JSON
{
  "data": {
    "transactionId": "d34e29e57a4c466e9fe033a0430f8f1b"
  }
}
FieldInstructions
transactionIdPlatform withdrawal flow number (channel transactionRefNum)

4.4 Check the withdrawal status ​

POST /openapi/v1/getPayoutStatusById

Business clear text (after AES decryption) ​

JSON
{
  "transactionId": "WD20260914113420BEDCEF",
  "type": 2
}

or

JSON
{
  "merchantOrderId": "MCH202609140001",
  "type": 2
}
FieldTypeRequiredInstructions
transactionIdStringOne of twoPlatform withdrawal ID (transactionId returned from create)
merchantOrderIdStringOne of twoMerchant withdrawal order number (corresponds to order\.id)
typeIntegerNoFixed pass 2 for payout query

transactionId and merchantOrderId are mutually exclusive; if both are provided, transactionId takes priority.

Business response in clear text ​

JSON
{
  "message": "Success",
  "type": 2,
  "data": {
    "status": "PENDING",
    "transaction": {
      "id": "d34e29e57a4c466e9fe033a0430f8f1b",
      "amountWithdrawn": 0,
      "confirmedAt": 0,
      "commissionFee": 0
    },
    "order": {},
    "recipient": {}
  }
}

**Note: **In the current implementation, the withdrawal status query is a placeholder logic and returns PENDING by default; the complete field backfilling depends on the subsequent withdrawal order table. Please take the **withdrawal callback **as the standard for production docking and compensate with active query.


5. Asynchronous callbacks (Webhooks) ​

After the payment/withdrawal result is determined, the platform sends an encrypted notification to the merchant's callback URL by asynchronous POST.

Which callback URL is used:

  1. If callbackUrl was passed when creating the payment / withdrawal, the notification goes to that URL (applies to that order only)
  2. Otherwise, it goes to the callback URL configured for the merchant (depositCallback for deposits, withdrawCallback for withdrawals)
  3. If neither is set, no notification is sent; use the query APIs to get the result

Merchants need to:

  1. AES Decryption Notification Body

  2. Validate business fields (order number, amount, status)

  3. Returns HTTP 200 (response {"response": true} or plain text OK is recommended)

  4. Handle ** idempotence ** properly (the same order may be notified repeatedly)

5.1 call back depositCallback ​

HTTP Body:

JSON
{
  "data": "AES加密的HEX密文",
  "orderId": "MCH202603170001"
}
FieldInstructions
dataAES-encrypted HEX ciphertext; see the plain text below
orderIdMerchant order number (corresponds to mchtOrderNo)

After decryption, the clear text is:

JSON
{
  "transactionId": "12026031700000001",
  "merchantOrderId": "MCH202603170001",
  "status": "SUCCESS",
  "amount": "88.50",
  "currency": "MYR",
  "timestamp": "1710652800000"
}
FieldInstructions
statusOrder Status: PENDING Pending / SUCCESS Success / FAILED Failure / CLOSED Closed / REFUNDED Refunded
timestampPush time in milliseconds

5.2 Callback for paymentwithdrawCallback ​

HTTP Body:

JSON
{
  "encryptedData": "AES加密的HEX密文",
  "orderId": "20260914033419822852948"
}
FieldInstructions
encryptedDataAES-encrypted HEX ciphertext; see the plain text below
orderIdMerchant withdrawal order number (corresponds to order\.id in the request body)

After decryption, the clear text is:

JSON
{
  "transactionId": "WD20260914113420BEDCEF",
  "merchantOrderId": "20260914033419822852948",
  "status": "SUCCESS",
  "amount": "150.00",
  "currency": "MYR",
  "timestamp": "1758787200000",
  "message": "Success",
  "data": {
    "status": "SUCCESS",
    "transaction": {
      "id": "WD20260914113420BEDCEF"
    }
  }
}
FieldInstructions
transactionIdPlatform withdrawal ID (same as transactionId in the payout response)
merchantOrderIdMerchant withdrawal order number (corresponds to order\.id in the request)
statusWithdrawal status: SUCCESS / FAILED / PENDING
amountWithdrawal amount
currencyCurrency, e.g. MYR
timestampCallback timestamp (milliseconds)
messageBackward-compatible field, fixed value Success
data.transaction.idBackward-compatible field, same as transactionId

The ciphertext field of the collection callback is named data, and that of the payment callback is named encryptedData; both callbacks carry orderId (collection = merchant order number, payment = merchant withdrawal order number). Please note the difference in ciphertext field names when integrating.


Error code ​

OpenAPI outer response code is an HTTP-style business code (not an HTTP status code):

codemessage (example)InstructionsHandling suggestions
200SuccessSuccessDecrypt encryptedData Access business data
400Missing required parametersHeader or body is missingCheck four Headers and data fields
400Data decryption failedAES decryption failedCheck symmetricKey , HEX format, IV/Key rules
400Invalid request bodyPlain text JSON cannot be parsedCheck the JSON format
400Missing order.idThe order lacks the merchant order numberComplete order.id
400Missing transactionId or merchantOrderIdCheck for missing platform order number or merchant order numberComplete transactionId or merchantOrderId
400Missing order or recipientThere is a lack of order/recipient information for withdrawalComplete the withdrawal structure
401Invalid clientIdclientId is not a numberCheck Authorization
401Unauthorized: clientId not foundThe merchant has not openedContact operation configuration
401Signature verification failedRSA signature verification failedCheck if the private key, the string to be signed, and the ciphertext are consistent
404Transaction not foundThe order does not existCheck transactionId
500Merchant application not foundApplication configuration is missingContact Operations
500Response encryption failedServer-level encryption anomalyRetry or contact technical support
500{Business error message}Failed to create an order or make a paymentInvestigate (channels, amounts, banks, etc.) according to the message

Common messages during the 500 stage of placing an order/making a payment:

  • The merchant has not set up a payment collection channel

  • Failed to create an order through the channel: ...

  • Email cannot be empty/ bankAccNumber cannot be emptyand so on (withdrawal parameter validation)

  • The original error JSON returned by the SecurePays channel


Enumeration Explanation ​

Deposit payment method (7.1) ​

CodingInstructionsMajor categories
CARDCredit / Debit CardCreditCard
TNG_MYTouch N GoEwallet
SHOPEEPAY_MYShopeePayEwallet
BOOST_MYBoostEwallet
DUITNOW_QR_MYDuitNOW QREwallet
GRABPAY_MYGrabPayEwallet
MAE_MYMAEEwallet
WECHATPAY_CNWechatPay (China)Ewallet
HLBB_MYHong Leong Bank BerhadFPX
UOB_MYUnited Overseas BankFPX
SCB_MYStandard Chartered BankFPX
RHB_MYRHB BankFPX
PBB_MYPublic Bank BerhadFPX
OCBC_MYOCBC Bank Malaysia BerhadFPX
MB2U_MYMalayan Banking Berhad (M2U)FPX
KFH_MYKuwait Finance House (Malaysia) BerhadFPX
HSBC_MYHSBC Bank Malaysia BerhadFPX
ABB_MYAffin BankFPX
CIMB_MYCIMB Bank BerhadFPX
BSN_MYBank Simpanan NasionalFPX
BMMB_MYBank Muamalat Malaysia BerhadFPX
BKRM_MYBank Kerjasama Rakyat Malaysia BerhadFPX
BIMB_MYBank Islam Malaysia BerhadFPX
ARGO_MYBank Pertanian (Agrobank)FPX
AMBB_MYAmBank Malaysia BerhadFPX
ABMB_MYAlliance Bank Malaysia BerhadFPX

7.2 Bank code for payment (Payout) ​

recipient.methodType

CodingInstructionsMajor categories
HLBB_MYHong Leong Bank BerhadFPX
UOB_MYUnited Overseas BankFPX
SCB_MYStandard Chartered BankFPX
RHB_MYRHB BankFPX
PBB_MYPublic Bank BerhadFPX
OCBC_MYOCBC Bank Malaysia BerhadFPX
MB2U_MYMalayan Banking Berhad (M2U)FPX
KFH_MYKuwait Finance House (Malaysia) BerhadFPX
HSBC_MYHSBC Bank Malaysia BerhadFPX
ABB_MYAffin BankFPX
CIMB_MYCIMB Bank BerhadFPX
BSN_MYBank Simpanan NasionalFPX
BMMB_MYBank Muamalat Malaysia BerhadFPX
BKRM_MYBank Kerjasama Rakyat Malaysia BerhadFPX
BIMB_MYBank Islam Malaysia BerhadFPX
ARGO_MYBank Pertanian (Agrobank)FPX
AMBB_MYAmBank Malaysia BerhadFPX
ABMB_MYAlliance Bank Malaysia BerhadFPX

Order status 7.3 ​

Query interface data.status

API statusMeaning
PENDINGPending payment/processing
SUCCESSPayment successful
FAILEDPayment failure
CLOSEDClosed
REFUNDEDRefunded

Pay on behalf ofStatus: Pending / Success / Failed

Currency 7.4 ​

ValueInstructions
MYRMalaysian Ringgit (Default)
USDTDocument reservation, whether to open or not is subject to the merchant's configuration

8. Best Practices ​

Security 8.1 ​

  • Production environmentMustConfigure RSA public key and enable signature verification; the private key is only stored at the merchant server level and is not allowed to be distributed to the Client or the front end.

  • SymmetricKey is only used for server level to server level communication, and regular rotation needs to be synchronized with the platform.

  • The callback interface checks the source IP (if the platform provides an IP allowlist) and verifies the amount and order number after decryption.

  • X-Nonce-StrEach time is unique, the platform side suggests to do anti-replay (you can record the used nonce yourself).

Order 8.2 and idempotence ​

  • Merchant order number order.id / Withdrawal order number must **be globally unique **; Submitting the same order number repeatedly may trigger channel idempotence or business errors.

  • Take transactionId (platform order number) as the reconciliation primary key, and save the merchant order number in the local order table at the same time.

  • Receive SUCCESS before shipping; to **callback + active inquiry **double insurance to confirm the final state.

Timeout and Retry 8.3 ​

  • It is recommended to set the HTTP Client connection timeout to 10 seconds and the read timeout to 30 seconds.

  • Network timeout **Do not **directly determine failure: use the same merchant order number to check the status, or wait for a callback.

  • The query interface supports exponential backoff retry (such as 3s, 10s, 30s, 60s) for up to 24 hours.

Amount of 8.4 ​

  • Use **string ** consistently for the amount, keeping two decimal places to avoid floating-point precision issues.

  • The minimum order amount is 0.01; the withdrawal amount is multiplied by 100 within the platform and then sent to the channel.

8.5 Joint Debug Checklist ​

  • [ ] Authorization = correct clientId

  • [ ] AES encryption and decryption HEX are consistent with the platform OpenApiUtil

  • [ ] The original four paragraphs are connected by\ n, and the fourth paragraph is encrypted data (not plaintext JSON)

  • [ ] X-Signature with sha256 prefix

  • [ ] The callback URL is publicly accessible and supports POST JSON

  • [ ] SIT Complete: Place an order → Pay → Callback → Check the whole-link


9. Java Demo ​

The warehouse provides standalone Java 17 examples (without Spring dependencies):

Plaintext
docs/demo/OpenApiClientDemo.java

9.1 Configure before running ​

Edit OpenApiClientDemo.java Top Constants:

Java
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;

import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.HexFormat;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

/**
 * Pay OpenAPI Merchant Integration Demo (single file, Java 11+, no third-party dependencies)
 *
 * ============================================================
 * Protocol Specification
 * ============================================================
 * 1. Body encryption: AES-256-CBC
 *    - Key  = first 32 bytes of symmetricKey (UTF-8)
 *    - IV   = first 16 bytes of Key
 *    - Output = lowercase HEX string
 *
 * 2. Signature algorithm: RSA-SHA256
 *    - Content to sign = clientId + "\n" + nonceStr + "\n" + timestamp + "\n" + encryptedData
 *    - Header: X-Signature: sha256 {Base64 signature}
 *
 * 3. Request headers (same for all endpoints):
 *    Authorization: {clientId}
 *    X-Nonce-Str:   random 16-character string
 *    X-Timestamp:   current timestamp in milliseconds
 *    X-Signature:   sha256 {Base64 signature}
 *    Content-Type:  application/json
 *
 * 4. Request body:   { "data": "{AES-encrypted HEX}" }
 * 5. Response body:  { "code": 200, "encryptedData": "{AES-encrypted HEX}", "message": "Success" }
 *
 * ============================================================
 * Endpoint List
 * ============================================================
 * POST /openapi/v1/createPayment            — Create collection (pay-in) order
 * POST /openapi/v1/getTransactionStatusById — Query collection status
 * POST /openapi/v1/withdrawRequest          — Payout / withdrawal
 * POST /openapi/v1/getPayoutStatusById      — Query withdrawal status
 *
 * ============================================================
 * Usage
 * ============================================================
 * 1. Fill in the configuration section below
 * 2. In the Pay console, set depositCallback / withdrawCallback
 *    to http://{public IP of your test server}:18080/callback
 * 3. javac PayDemo.java && java PayDemo
 */
public class PayDemo {

    // ============================================================
    // ★ Configuration
    // ============================================================

    /** Pay platform URL (test environment) */
    //static final String BASE_URL = "https://hpay.jyoou.com/";
    static final String BASE_URL = "http://localhost:7104/";

    /** Merchant clientId (corresponds to c_app_security.client_id) */
    static final String CLIENT_ID = "43271";

    /** Merchant AES symmetric key (32-character string) */
    static final String SYMMETRIC_KEY = "";

    /**
     * Merchant RSA private key (PKCS#8 format, plain Base64,
     * with BEGIN/END lines and line breaks removed)
     * The corresponding public key has been registered in the Pay
     * console as c_app_security.server_public_key
     */
    static final String PRIVATE_KEY_PKCS8 =
            "";

    /**
     * Local callback listening port (the platform pushes notifications to this port)
     * Configure depositCallback / withdrawCallback in the console as:
     *   http://{your public IP or ngrok address}:18080/callback
     */
    static final int CALLBACK_PORT = 18080;

    /**
     * Timeout for waiting for the async callback (seconds)
     * Set to 0 to skip waiting and exit immediately
     */
    static final int CALLBACK_WAIT_SECONDS = 120;

    // ============================================================

    /** Latch signaled when a callback is received (used by the main thread to wait) */
    private static final CountDownLatch callbackLatch = new CountDownLatch(1);

    public static void main(String[] args) throws Exception {
        System.out.println("===========================================");
        System.out.println("  Pay OpenAPI Merchant Integration Demo");
        System.out.println("  BASE_URL  = " + BASE_URL);
        System.out.println("  CLIENT_ID = " + CLIENT_ID);
        System.out.println("===========================================\n");

        // ── Start the callback server ─────────────────────────────
        HttpServer callbackServer = startCallbackServer(CALLBACK_PORT);
        System.out.println("✓ Callback server started, listening on http://localhost:" + CALLBACK_PORT + "/callback");
        System.out.println("  Make sure depositCallback in the Pay console is set to the address above\n");

        // ── Demo 1: Create collection order ───────────────────────
        System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
        System.out.println("[Demo 1] Create payment POST /openapi/v1/createPayment");
        System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
        String merchantOrderId = "ORD-" + System.currentTimeMillis();
        String createPaymentBody = buildCreatePaymentBody(
                merchantOrderId, "20.00", "MYR", "Test Payment",
                "melody merchant", "fql_ww@163.com", "0123456789");
        System.out.println("  Request plaintext: " + createPaymentBody);

        ApiResponse createResp = callApi("/openapi/v1/createPayment", createPaymentBody);
        System.out.println("  Response code=" + createResp.code + "  message=" + createResp.message);
        if (createResp.plainData != null) {
            System.out.println("  Decrypted: " + createResp.plainData);
        }

        String transactionId = createResp.plainData != null
                ? extractField(createResp.plainData, "transactionId") : null;
        String paymentUrl = createResp.plainData != null
                ? extractField(createResp.plainData, "paymentUrl") : null;
        System.out.println("  ✓ transactionId = " + transactionId);
        System.out.println("  ✓ paymentUrl    = " + paymentUrl);
        System.out.println();

        // ── Demo 2: Query collection status ───────────────────────
        if (transactionId != null && !transactionId.isEmpty()) {
            System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
            System.out.println("[Demo 2] Query payment status POST /openapi/v1/getTransactionStatusById");
            System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
            String queryBody = buildQueryStatusBody(transactionId, 1);
            System.out.println("  Request plaintext: " + queryBody);
            ApiResponse queryResp = callApi("/openapi/v1/getTransactionStatusById", queryBody);
            System.out.println("  Response code=" + queryResp.code + "  message=" + queryResp.message);
            if (queryResp.plainData != null) {
                System.out.println("  Decrypted: " + queryResp.plainData);
                System.out.println("  ✓ status = " + extractField(queryResp.plainData, "status"));
            }
            System.out.println();
        }

        // ── Demo 3: Payout / withdrawal ───────────────────────────
        System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
        System.out.println("[Demo 3] Payout / withdrawal POST /openapi/v1/withdrawRequest");
        System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
        String payoutOrderId = "PAYOUT-" + System.currentTimeMillis();
        String payoutBody = buildPayoutBody(
                payoutOrderId, "11.00", "MYR",
                "Long Wan", "0123456789", "test@example.com",
                "Affin Bank", "1", "");
        System.out.println("  Request plaintext: " + payoutBody);



        ApiResponse payoutResp = callApi("/openapi/v1/withdrawRequest", payoutBody);
        System.out.println("  Response code=" + payoutResp.code + "  message=" + payoutResp.message);
        if (payoutResp.plainData != null) {
            System.out.println("  Decrypted: " + payoutResp.plainData);
        }

        String payoutTxnId = payoutResp.plainData != null
                ? extractField(payoutResp.plainData, "transactionId") : null;
        System.out.println("  ✓ Payout transactionId = " + payoutTxnId);
        System.out.println();

        // ── Demo 4: Query withdrawal status ───────────────────────
        if (payoutTxnId != null && !payoutTxnId.isEmpty()) {
            System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
            System.out.println("[Demo 4] Query payout status POST /openapi/v1/getPayoutStatusById");
            System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
            String payoutQueryBody = buildQueryStatusBody(payoutTxnId, 2);
            System.out.println("  Request plaintext: " + payoutQueryBody);
            ApiResponse payoutQueryResp = callApi("/openapi/v1/getPayoutStatusById", payoutQueryBody);
            System.out.println("  Response code=" + payoutQueryResp.code + "  message=" + payoutQueryResp.message);
            if (payoutQueryResp.plainData != null) {
                System.out.println("  Decrypted: " + payoutQueryResp.plainData);
                System.out.println("  ✓ status = " + extractField(payoutQueryResp.plainData, "status"));
            }
            System.out.println();
        }

        // ── Demo 5: Wait for async callbacks from the platform ────
        System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
        System.out.println("[Demo 5] Waiting for platform async callbacks (depositCallback / withdrawCallback)");
        System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
        if (CALLBACK_WAIT_SECONDS > 0) {
            System.out.println("  Waiting up to " + CALLBACK_WAIT_SECONDS + "s, callback URL: http://{public IP of your test server}:" + CALLBACK_PORT + "/callback");
            System.out.println("  Make sure depositCallback / withdrawCallback in the Pay console are set to the address above");
            System.out.println("  (Ensure the firewall / security group allows port " + CALLBACK_PORT + ")\n");
            boolean received = callbackLatch.await(CALLBACK_WAIT_SECONDS, TimeUnit.SECONDS);
            if (!received) {
                System.out.println("  ⚠ Timed out without receiving a callback (an actual payment may be required, or check the callback URL configuration in the console)");
            }
        } else {
            System.out.println("  CALLBACK_WAIT_SECONDS=0, skipping wait");
        }

        callbackServer.stop(0);
        System.out.println("\n===========================================");
        System.out.println("  Demo finished");
        System.out.println("===========================================");
    }

    // ──────────────────────────────────────────────────────────────
    // Callback server (actually receives platform notifications)
    // ──────────────────────────────────────────────────────────────

    /**
     * Start an embedded HTTP server to listen for platform callbacks
     * Callback format: POST { "encryptedData": "HEX" }
     * After processing, the merchant must reply: HTTP 200 {"response":true}
     */
    static HttpServer startCallbackServer(int port) throws IOException {
        HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
        server.createContext("/callback", exchange -> handleCallback(exchange));
        server.start();
        return server;
    }

    static void handleCallback(HttpExchange exchange) throws IOException {
        if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
            exchange.sendResponseHeaders(405, 0);
            exchange.close();
            return;
        }
        try {
            // Read the request body
            InputStream is = exchange.getRequestBody();
            String body = new String(is.readAllBytes(), StandardCharsets.UTF_8);
            System.out.println("\n  ══════════════════════════════════════");
            System.out.println("  ★ Platform callback received!");
            System.out.println("  Raw body: " + body);

            // Decrypt encryptedData
            String encryptedData = extractField(body, "encryptedData");
            if (encryptedData != null && !encryptedData.isEmpty()) {
                String plainText = aesDecrypt(encryptedData, SYMMETRIC_KEY);
                System.out.println("  Decrypted plaintext: " + plainText);

                // Business logic: update local order status based on status
                String status = extractField(plainText, "status");
                String txnId = extractField(plainText, "transactionId");
                System.out.println("  ✓ transactionId=" + txnId + "  status=" + status);
                System.out.println("  → Merchant-side order status update: " + (
                        "SUCCESS".equals(status) ? "Payment succeeded, proceed with follow-up business logic" : "status=" + status));
            } else {
                System.out.println("  ⚠ encryptedData field not found");
            }
            System.out.println("  ══════════════════════════════════════\n");

            // Must reply {"response":true} (for collection) or HTTP 200
            byte[] response = "{\"response\":true}".getBytes(StandardCharsets.UTF_8);
            exchange.getResponseHeaders().add("Content-Type", "application/json");
            exchange.sendResponseHeaders(200, response.length);
            try (OutputStream os = exchange.getResponseBody()) {
                os.write(response);
            }

            // Notify the main thread that a callback has been received
            callbackLatch.countDown();

        } catch (Exception e) {
            System.err.println("  ✗ Callback handling error: " + e.getMessage());
            exchange.sendResponseHeaders(500, 0);
            exchange.close();
        }
    }

    // ──────────────────────────────────────────────────────────────
    // Request body builders
    // ──────────────────────────────────────────────────────────────

    /** Collection order plaintext (JSON keys in alphabetical order) */
    static String buildCreatePaymentBody(String orderId, String amount, String currency,
                                          String title, String name, String email, String phone) {
        return "{" +
                "\"customer\":{" +
                    "\"email\":\"" + email + "\"," +
                    "\"name\":\"" + name + "\"," +
                    "\"phone\":\"" + phone + "\"" +
                "}," +
                "\"method\":\"FPX\"," +
                "\"order\":{" +
                    "\"amount\":\"" + amount + "\"," +
                    "\"currencyType\":\"" + currency + "\"," +
                    "\"id\":\"" + orderId + "\"," +
                    "\"title\":\"" + title + "\"" +
                "}" +
                "}";
    }

    /** Status query plaintext (type=1 collection / type=2 payout) */
    static String buildQueryStatusBody(String transactionId, int type) {
        return "{\"transactionId\":\"" + transactionId + "\",\"type\":" + type + "}";
    }

    /** Payout / withdrawal plaintext */
    static String buildPayoutBody(String orderId, String amount, String currency,
                                   String name, String phone, String email,
                                   String methodType, String methodValue, String methodRef) {
        return "{" +
                "\"order\":{" +
                    "\"amount\":\"" + amount + "\"," +
                    "\"currencyType\":\"" + currency + "\"," +
                    "\"id\":\"" + orderId + "\"" +
                "}," +
                "\"recipient\":{" +
                    "\"email\":\"" + email + "\"," +
                    "\"methodRef\":\"" + methodRef + "\"," +
                    "\"methodType\":\"" + methodType + "\"," +
                    "\"methodValue\":\"" + methodValue + "\"," +
                    "\"name\":\"" + name + "\"," +
                    "\"phone\":\"" + phone + "\"" +
                "}" +
                "}";
    }


    // ──────────────────────────────────────────────────────────────
    // Core HTTP call (actually POSTs to the Pay platform)
    // ──────────────────────────────────────────────────────────────

    static ApiResponse callApi(String path, String plainBody) throws Exception {
        // Step1: AES encryption
        String encryptedData = aesEncrypt(plainBody, SYMMETRIC_KEY);

        // Step2: generate random nonceStr and timestamp
        String nonceStr = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
        String timestamp = String.valueOf(System.currentTimeMillis());

        // Step3: content to sign = clientId\nnonceStr\ntimestamp\nencryptedData
        String signContent = CLIENT_ID + "\n" + nonceStr + "\n" + timestamp + "\n" + encryptedData;
        String signature = rsaSign(signContent, PRIVATE_KEY_PKCS8);

        // Step4: build the request body
        String requestBody = "{\"data\":\"" + encryptedData + "\"}";

        System.out.println("  → nonceStr=" + nonceStr + "  timestamp=" + timestamp);
        System.out.println("  → Content to sign (first 80 chars): " + signContent.substring(0, Math.min(80, signContent.length())) + "...");

        // Step5: send HTTP POST
        HttpClient client = HttpClient.newHttpClient();
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(BASE_URL + path))
                .header("Content-Type", "application/json")
                .header("Authorization", CLIENT_ID)
                .header("X-Nonce-Str", nonceStr)
                .header("X-Timestamp", timestamp)
                .header("X-Signature", "sha256 " + signature)
                .POST(HttpRequest.BodyPublishers.ofString(requestBody))
                .build();

        HttpResponse<String> httpResp = client.send(request, HttpResponse.BodyHandlers.ofString());
        System.out.println("  ← HTTP " + httpResp.statusCode());
        System.out.println("  ← Body: " + httpResp.body());

        // Step6: parse and decrypt the response
        return parseAndDecryptResponse(httpResp.body());
    }

    static ApiResponse parseAndDecryptResponse(String json) {
        ApiResponse resp = new ApiResponse();
        try {
            resp.code = Integer.parseInt(extractField(json, "code"));
        } catch (Exception ignored) {}
        resp.message = extractField(json, "message");
        String encryptedData = extractField(json, "encryptedData");
        if (encryptedData != null && !encryptedData.isEmpty()) {
            try {
                resp.plainData = aesDecrypt(encryptedData, SYMMETRIC_KEY);
            } catch (Exception e) {
                resp.plainData = "[Decryption failed] " + e.getMessage();
            }
        }
        return resp;
    }

    // ──────────────────────────────────────────────────────────────
    // AES-256-CBC (Key = first 32 bytes, IV = first 16 bytes of Key, HEX output)
    // ──────────────────────────────────────────────────────────────

    static String aesEncrypt(String plainText, String symmetricKey) {
        try {
            byte[] keyData = buildKeyBytes(symmetricKey);
            byte[] ivData = new byte[16];
            System.arraycopy(keyData, 0, ivData, 0, 16);
            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
            cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keyData, "AES"), new IvParameterSpec(ivData));
            return HexFormat.of().formatHex(cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)));
        } catch (Exception e) {
            throw new RuntimeException("AES encrypt failed: " + e.getMessage(), e);
        }
    }

    static String aesDecrypt(String hexCipher, String symmetricKey) {
        try {
            byte[] keyData = buildKeyBytes(symmetricKey);
            byte[] ivData = new byte[16];
            System.arraycopy(keyData, 0, ivData, 0, 16);
            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
            cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyData, "AES"), new IvParameterSpec(ivData));
            return new String(cipher.doFinal(HexFormat.of().parseHex(hexCipher)), StandardCharsets.UTF_8);
        } catch (Exception e) {
            throw new RuntimeException("AES decrypt failed: " + e.getMessage(), e);
        }
    }

    static byte[] buildKeyBytes(String symmetricKey) {
        byte[] raw = symmetricKey.getBytes(StandardCharsets.UTF_8);
        byte[] keyData = new byte[32];
        System.arraycopy(raw, 0, keyData, 0, Math.min(raw.length, 32));
        return keyData;
    }

    // ──────────────────────────────────────────────────────────────
    // RSA-SHA256 signing
    // ──────────────────────────────────────────────────────────────

    static String rsaSign(String content, String privateKeyBase64) throws Exception {
        String clean = privateKeyBase64
                .replace("-----BEGIN PRIVATE KEY-----", "")
                .replace("-----END PRIVATE KEY-----", "")
                .replaceAll("\\s+", "");
        PrivateKey key = KeyFactory.getInstance("RSA")
                .generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(clean)));
        Signature sig = Signature.getInstance("SHA256withRSA");
        sig.initSign(key);
        sig.update(content.getBytes(StandardCharsets.UTF_8));
        return Base64.getEncoder().encodeToString(sig.sign());
    }

    // ──────────────────────────────────────────────────────────────
    // Simple JSON field extraction (no third-party dependencies)
    // ──────────────────────────────────────────────────────────────

    static String extractField(String json, String key) {
        if (json == null) return null;
        int idx = json.indexOf("\"" + key + "\"");
        if (idx < 0) return null;
        int colon = json.indexOf(':', idx);
        if (colon < 0) return null;
        int start = colon + 1;
        while (start < json.length() && json.charAt(start) == ' ') start++;
        if (start >= json.length()) return null;
        if (json.charAt(start) == '"') {
            int end = json.indexOf('"', start + 1);
            return end < 0 ? null : json.substring(start + 1, end);
        } else {
            int end = start;
            while (end < json.length() && ",}]".indexOf(json.charAt(end)) < 0) end++;
            return json.substring(start, end).trim();
        }
    }

    static class ApiResponse {
        int code;
        String message;
        String plainData;
    }
}

Default demonstration order: Place an order → Check the collection status. You can uncomment main to test the withdrawal interface.


Appendix: Interface Quick Reference ​

MethodPathFunction
POST/openapi/v1/createPaymentMerchants place an order (for collection)
POST/openapi/v1/getTransactionStatusByIdCheck the collection status
POST/openapi/v1/withdrawRequestInitiate withdrawal (on behalf of payment)
POST/openapi/v1/getPayoutStatusByIdCheck the withdrawal status

Built with VitePress. Markdown content is managed in Git.