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"
}
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
methodStringNoFor the expected payment method, please refer to 7.1 Collection Payment Method

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

4.2 Check the collection status

POST /openapi/v1/getTransactionStatusById

Business clear text (after AES decryption)

JSON
{
  "transactionId": "12026031700000001",
  "type": 1
}
FieldTypeRequiredInstructions
transactionIdStringYesPlatform Order Number ( transactionId returned )
typeIntegerNoFixed Transmission 1 Indicates the inquiry for collection (reserved field)

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": ""
  }
}
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

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": "d34e29e57a4c466e9fe033a0430f8f1b",
  "type": 2
}
FieldTypeRequiredInstructions
transactionIdStringYesPlatform withdrawal transaction number
typeIntegerNoFixed Pass2 represents the payment inquiry

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 configures the callback URL to the merchant for **asynchronous POST **encryption notification. 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密文"
}

After decryption, the clear text is:

JSON
{
  "transactionId": "12026031700000001",
  "merchantOrderId": "MCH202603170001",
  "status": "01",
  "amount": "88.50",
  "currency": "MYR",
  "timestamp": "1710652800000"
}
FieldInstructions
statusInternal Order Status Code: 00 Pending/ 01 Success/ 02 Failure/ 03 Closed/ 04 Refund
timestampPush time in milliseconds

5.2 Callback for paymentwithdrawCallback

HTTP Body:

JSON
{
  "encryptedData": "AES加密的HEX密文"
}

After decryption, the clear text is:

JSON
{
  "message": "Success",
  "data": {
    "status": "SUCCESS",
    "transaction": {
      "id": "d34e29e57a4c466e9fe033a0430f8f1b"
    }
  }
}

The callback field for collection is named data, and the callback field for payment is encryptedData. Please note the difference 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 transactionIdCheck for missing platform order numbersComplete transactionId
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
FPXFPX Online Banking (users select a specific bank on the checkout page)FPX
CIMB_MYCIMB BankFPX
MBB_MYMaybankFPX
PBB_MYPublic BankFPX
RHB_MYRHB BankFPX
HLB_MYHong Leong BankFPX
ABB_MYAffin BankFPX
AMB_MYAmBankFPX
BSN_MYNational Savings BankFPX
TNG_MYTouch 'n Go eWalleteWallet
GRB_MYGrabPayeWallet
BOOST_MYBoosteWallet
SHP_MYShopeePayeWallet

7.2 Bank code for payment (Payout)

recipient.methodType

CodingInstructionsMajor categories
FPXFPX Online Banking (users select a specific bank on the checkout page)FPX
CIMB_MYCIMB BankFPX
MBB_MYMaybankFPX
PBB_MYPublic BankFPX
RHB_MYRHB BankFPX
HLB_MYHong Leong BankFPX
ABB_MYAffin BankFPX
AMB_MYAmBankFPX
BSN_MYNational Savings BankFPX
TNG_MYTouch 'n Go eWalleteWallet
GRB_MYGrabPayeWallet
BOOST_MYBoosteWallet
SHP_MYShopeePayeWallet

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.