OpenAPI v1 Merchant Integration Document
Directory
Access Preparation
General Agreement
Encryption, decryption, and signing
Interface list
Asynchronous callback (Webhook)
Error code
Enumeration description
Best practices
Java Demo
1. Access Preparation
After the merchant completes the development settings in the management backend, they will receive the following information
| Configuration item | Purpose |
|---|---|
clientId | Include Authorization in the request header to identify the merchant's identity |
symmetricKey | 32-bit AES symmetric key, encryption/decryption request/response body |
serverPublicKey | The merchant's RSA public key (PEM) is used by the platform for signature verification. The merchant retains the corresponding private key for signing |
depositCallback | URL for asynchronous notification of collection (recharge) results |
withdrawCallback | Asynchronous notification URL for payment (withdrawal) results |
Environment address (example, subject to actual deployment):
| Environment | Base URL |
|---|---|
| SIT | https://{sit_host}/openapi/v1 |
| Production | https://{prod_host}/openapi/v1 |
2. General Agreement
2.1 Public Request Header
All OpenAPI interfaces use POST and must carry the following headers:
| Header | Required | Instructions |
|---|---|---|
Authorization | Yes | Merchant clientId (pure numerical string) |
X-Nonce-Str | Yes | Random string, UUID is recommended, unique for each request to prevent replay |
X-Timestamp | Yes | Millisecond timestamp (string), recommended to be within ±5 minutes of the server time difference |
X-Signature | Yes | Format: sha256 {Base64 signature} |
Content-Type | Yes | application/json |
2.2 Public HTTP request body (outer layer)
{
"data": "Business plaintext after AES encryption (HEX lowercase string)"
}2.3 Public HTTP response body (outer layer)
Success:
{
"code": 200,
"encryptedData": "Business plain text (HEX lowercase string) encrypted with AES",
"message": "Success"
}Failure:
{
"code": 400,
"message": "Missing required parameters"
}When failing, there is usually no
encryptedDatafield, please handle according tocode+message.
2.4 Business encryption and decryption process
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
| Item | Rules |
|---|---|
| Algorithm | AES/CBC/PKCS5Padding |
| Key | symmetricKey UTF-8 ByteDance's previous 32 ByteDance |
| IV | Key's previous **16 **ByteDance |
| Cipher code | Binary cipher conversion **lowercase HEX **string |
3.2 RSA-SHA256 Signature (Merchant Request Signature)
To be signed string (original text):
{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:
Use merchant RSA **private key **(PKCS #8 PEM) to
SHA256withRSAsignatureSignature result Base64 encoding
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)
{
"order": {
"id": "MCH202603170001",
"title": "商品购买",
"amount": "88.50",
"currencyType": "MYR",
"additionalData": ""
},
"customer": {
"name": "John Doe",
"phone": "60123456789",
"email": "john@example.com"
},
"method": "CIMB_MY"
}| Field | Type | Required | Instructions |
|---|---|---|---|
order.id | String | Yes | Merchant order number, globally unique, corresponding to the platform mchtOrderNo |
order.title | String | No | Order title/description |
order.amount | String | Yes | Order amount, such as "88.50", minimum 0.01 |
order.currencyType | String | No | Currency, default MYR |
order.additionalData | String | No | Additional data |
customer.name | String | No | Customer's name |
customer.phone | String | No | Customer's mobile phone number |
customer.email | String | No | Customer email |
method | String | No | For the expected payment method, please refer to 7.1 Collection Payment Method |
Business response in clear text (after AES decryption) encryptedData
{
"data": {
"paymentUrl": "https://checkout.example.com/...",
"transactionId": "12026031700000001"
}
}| Field | Instructions |
|---|---|
paymentUrl | Guide users to complete the payment through the checkout page/payment redirect link |
transactionId | Platform order number, please persist for subsequent inquiries and reconciliation |
Typical failure message
| message | The reason |
|---|---|
Missing order.id | The merchant order number has not been sent |
The merchant has not set up a payment collection channel | Merchants 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)
{
"transactionId": "12026031700000001",
"type": 1
}| Field | Type | Required | Instructions |
|---|---|---|---|
transactionId | String | Yes | Platform Order Number ( transactionId returned ) |
type | Integer | No | Fixed Transmission 1 Indicates the inquiry for collection (reserved field) |
Business response in clear text
{
"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"
}
}
}| Field | Instructions |
|---|---|
data.status | Order status, see 7.3 Order Status |
transaction.id | Platform order number |
transaction.amountPaid | Amount actually paid |
transaction.confirmedAt | Payment confirmation time |
transaction.paymentMethod | Actual payment method |
transaction.commissionFee | Handling fee |
order.id | Merchant 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)
{
"order": {
"id": "WD202603170001",
"amount": "100.00",
"currencyType": "MYR"
},
"recipient": {
"name": "James Lee",
"phone": "60123456789",
"email": "james@example.com",
"methodType": "CIBBMYKL",
"methodValue": "8044591766",
"methodRef": ""
}
}| Field | Type | Required | Instructions |
|---|---|---|---|
order.id | String | Yes | Merchant withdrawal slip number, unique |
order.amount | String | Yes | Withdrawal amount (yuan), such as "100.00"; the platform internally converts to points (×100) |
order.currencyType | String | No | Currency, default MYR |
recipient.name | String | Yes | Name of the recipient |
recipient.phone | String | No | Receiver's mobile phone |
recipient.email | String | Yes | Receiver's email |
recipient.methodType | String | Yes | Payment bank logo, see 7.2 bank code for payment |
recipient.methodValue | String | Yes | Bank account |
recipient.methodRef | String | No | Additional reference information |
Business response in clear text
{
"data": {
"transactionId": "d34e29e57a4c466e9fe033a0430f8f1b"
}
}| Field | Instructions |
|---|---|
transactionId | Platform withdrawal flow number (channel transactionRefNum) |
4.4 Check the withdrawal status
POST /openapi/v1/getPayoutStatusById
Business clear text (after AES decryption)
{
"transactionId": "d34e29e57a4c466e9fe033a0430f8f1b",
"type": 2
}| Field | Type | Required | Instructions |
|---|---|---|---|
transactionId | String | Yes | Platform withdrawal transaction number |
type | Integer | No | Fixed Pass2 represents the payment inquiry |
Business response in clear text
{
"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
PENDINGby 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:
AES Decryption Notification Body
Validate business fields (order number, amount, status)
Returns HTTP 200 (response
{"response": true}or plain textOKis recommended)Handle ** idempotence ** properly (the same order may be notified repeatedly)
5.1 call back depositCallback
HTTP Body:
{
"data": "AES加密的HEX密文"
}After decryption, the clear text is:
{
"transactionId": "12026031700000001",
"merchantOrderId": "MCH202603170001",
"status": "01",
"amount": "88.50",
"currency": "MYR",
"timestamp": "1710652800000"
}| Field | Instructions |
|---|---|
status | Internal Order Status Code: 00 Pending/ 01 Success/ 02 Failure/ 03 Closed/ 04 Refund |
timestamp | Push time in milliseconds |
5.2 Callback for paymentwithdrawCallback
HTTP Body:
{
"encryptedData": "AES加密的HEX密文"
}After decryption, the clear text is:
{
"message": "Success",
"data": {
"status": "SUCCESS",
"transaction": {
"id": "d34e29e57a4c466e9fe033a0430f8f1b"
}
}
}The callback field for collection is named
data, and the callback field for payment isencryptedData. Please note the difference when integrating.
Error code
OpenAPI outer response code is an HTTP-style business code (not an HTTP status code):
| code | message (example) | Instructions | Handling suggestions |
|---|---|---|---|
200 | Success | Success | Decrypt encryptedData Access business data |
400 | Missing required parameters | Header or body is missing | Check four Headers and data fields |
400 | Data decryption failed | AES decryption failed | Check symmetricKey , HEX format, IV/Key rules |
400 | Invalid request body | Plain text JSON cannot be parsed | Check the JSON format |
400 | Missing order.id | The order lacks the merchant order number | Complete order.id |
400 | Missing transactionId | Check for missing platform order numbers | Complete transactionId |
400 | Missing order or recipient | There is a lack of order/recipient information for withdrawal | Complete the withdrawal structure |
401 | Invalid clientId | clientId is not a number | Check Authorization |
401 | Unauthorized: clientId not found | The merchant has not opened | Contact operation configuration |
401 | Signature verification failed | RSA signature verification failed | Check if the private key, the string to be signed, and the ciphertext are consistent |
404 | Transaction not found | The order does not exist | Check transactionId |
500 | Merchant application not found | Application configuration is missing | Contact Operations |
500 | Response encryption failed | Server-level encryption anomaly | Retry or contact technical support |
500 | {Business error message} | Failed to create an order or make a payment | Investigate (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 channelFailed 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)
| Coding | Instructions | Major categories |
|---|---|---|
FPX | FPX Online Banking (users select a specific bank on the checkout page) | FPX |
CIMB_MY | CIMB Bank | FPX |
MBB_MY | Maybank | FPX |
PBB_MY | Public Bank | FPX |
RHB_MY | RHB Bank | FPX |
HLB_MY | Hong Leong Bank | FPX |
ABB_MY | Affin Bank | FPX |
AMB_MY | AmBank | FPX |
BSN_MY | National Savings Bank | FPX |
TNG_MY | Touch 'n Go eWallet | eWallet |
GRB_MY | GrabPay | eWallet |
BOOST_MY | Boost | eWallet |
SHP_MY | ShopeePay | eWallet |
7.2 Bank code for payment (Payout)
recipient.methodType
| Coding | Instructions | Major categories |
|---|---|---|
FPX | FPX Online Banking (users select a specific bank on the checkout page) | FPX |
CIMB_MY | CIMB Bank | FPX |
MBB_MY | Maybank | FPX |
PBB_MY | Public Bank | FPX |
RHB_MY | RHB Bank | FPX |
HLB_MY | Hong Leong Bank | FPX |
ABB_MY | Affin Bank | FPX |
AMB_MY | AmBank | FPX |
BSN_MY | National Savings Bank | FPX |
TNG_MY | Touch 'n Go eWallet | eWallet |
GRB_MY | GrabPay | eWallet |
BOOST_MY | Boost | eWallet |
SHP_MY | ShopeePay | eWallet |
Order status 7.3
Query interface data.status
| API status | Meaning |
|---|---|
PENDING | Pending payment/processing |
SUCCESS | Payment successful |
FAILED | Payment failure |
CLOSED | Closed |
REFUNDED | Refunded |
Pay on behalf ofStatus: Pending / Success / Failed
Currency 7.4
| Value | Instructions |
|---|---|
MYR | Malaysian Ringgit (Default) |
USDT | Document 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.
SymmetricKeyis 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
SUCCESSbefore 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):
docs/demo/OpenApiClientDemo.java9.1 Configure before running
Edit OpenApiClientDemo.java Top Constants:
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
| Method | Path | Function |
|---|---|---|
| POST | /openapi/v1/createPayment | Merchants place an order (for collection) |
| POST | /openapi/v1/getTransactionStatusById | Check the collection status |
| POST | /openapi/v1/withdrawRequest | Initiate withdrawal (on behalf of payment) |
| POST | /openapi/v1/getPayoutStatusById | Check the withdrawal status |
