Java SDK for DynamicPay Online Payment Page (OPP). Supports Spring Boot auto-configuration.
Requirements: JDK 11+, Spring Boot 2.x / 3.x
Add the JitPack repository and dependency to your pom.xml:
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>com.github.Dynamic-Payment</groupId>
<artifactId>dynamicpay-opp-java-sdk</artifactId>
<version>1.1.1</version>
</dependency>
</dependencies>git clone https://github.com/Dynamic-Payment/dynamicpay-opp-java-sdk.git
cd dynamicpay-opp-java-sdk
# Compile and install to local Maven repository
mvn clean install -DskipTestsThen add to your project's pom.xml:
<dependency>
<groupId>com.dynamicpay.opp</groupId>
<artifactId>dynamicpay-opp-java-sdk</artifactId>
<version>1.1.1</version>
</dependency>Log in to the DynamicPay merchant portal, go to Account Settings → API Keys, and download your private key file (private_key_pkcs8.pem). Store it in a secure location on your server, e.g.:
/etc/opp/private_key_pkcs8.pem
opp:
company-id: YOUR_COMPANY_ID # Your company/merchant ID
private-key-path: /etc/opp/private_key_pkcs8.pem # Absolute path to private key file
environment: sandbox # sandbox or prodThe SDK auto-configures itself via Spring Boot — no extra @Bean setup needed.
⚠ Breaking change in 1.0.2: When
opp.environmentis omitted, the SDK now defaults toprod(previouslysandbox). If your application relied on the previoussandboxdefault, you must setopp.environment: sandboxexplicitly to keep using sandbox.
Inject OppClient directly into your service:
import com.dynamicpay.opp.sdk.client.OppClient;
@Service
public class OrderService {
private final OppClient oppClient;
public OrderService(OppClient oppClient) {
this.oppClient = oppClient;
}
}The payment page displays all available payment methods for the user to choose.
public String createOrder(String orderId, long amountCents) {
var request = new PaymentRequest();
request.setMerchantOrderNum(orderId); // Unique order ID, max 32 chars
request.setAmount(amountCents); // Amount in smallest unit (cents), e.g. 5000 = $50.00
request.setCurrency("USD");
request.setDescription("Order " + orderId);
request.setNotifyUrl("https://your-domain.com/payment/notify");
request.setRedirectSuccessUrl("https://your-domain.com/order/" + orderId + "/success");
request.setRedirectErrorUrl("https://your-domain.com/order/" + orderId + "/failed");
request.setRedirectCallerUrl("https://your-domain.com/cart"); // Back button destination
var response = oppClient.createPaymentUrl(request);
return response.getPayUrl(); // Redirect the user to this URL
}Specify paymentType to skip the payment method selection screen and go directly to Alipay.
Service layer:
import com.dynamicpay.opp.sdk.client.OppClient;
import com.dynamicpay.opp.sdk.model.PaymentRequest;
import com.dynamicpay.opp.sdk.model.PaymentResponse;
@Service
public class OrderService {
private final OppClient oppClient;
public OrderService(OppClient oppClient) {
this.oppClient = oppClient;
}
public String createAlipayOrder(String orderId, long amountCents) {
var request = new PaymentRequest();
// Required fields
request.setMerchantOrderNum(orderId);
request.setAmount(amountCents); // e.g. 12800 = $128.00
request.setCurrency("USD");
// Specify Alipay directly
// Supported values: alipay / wechat / unionpay / vmpay
request.setPaymentType("alipay");
request.setDescription("Order " + orderId);
// Async server-to-server notification (does not depend on browser redirect)
request.setNotifyUrl("https://your-domain.com/payment/notify");
// Page redirect URLs after payment completes
request.setRedirectSuccessUrl("https://your-domain.com/order/" + orderId + "/success");
request.setRedirectErrorUrl("https://your-domain.com/order/" + orderId + "/failed");
request.setRedirectCallerUrl("https://your-domain.com/cart");
PaymentResponse response = oppClient.createPaymentUrl(request);
// Generated payUrl example:
// https://uat-opp.dynamicg.com/payment
// ?orderNum=DP2024001
// &accessKey=eyJ...
// &redirectCallerUrl=https://your-domain.com/cart
// &redirectSuccessUrl=https://your-domain.com/order/ORD001/success
// &redirectErrorUrl=https://your-domain.com/order/ORD001/failed
return response.getPayUrl();
}
}Controller layer:
@RestController
@RequestMapping("/order")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping("/pay")
public ResponseEntity<Map<String, String>> pay(@RequestBody PayOrderRequest req) {
String payUrl = orderService.createAlipayOrder(req.getOrderId(), req.getAmount());
// Return payUrl to the frontend, then do: window.location.href = payUrl
return ResponseEntity.ok(Map.of("payUrl", payUrl));
}
}Async notification receiver (notifyUrl endpoint):
@RestController
@RequestMapping("/payment")
public class NotifyController {
@PostMapping("/notify")
public ResponseEntity<String> handleNotify(@RequestBody Map<String, Object> body) {
String orderNum = (String) body.get("orderNum");
// The webhook is NOT signed — do not trust the body directly.
// Always call queryBySign with orderNum to confirm status before
// marking the order paid. The webhook fires at most once; there is no retry.
return ResponseEntity.ok("success");
}
}Alipay payment flow:
Your Backend DynamicPay Platform User Browser
│ │ │
│── createPaymentUrl(request) ──→ │ │
│←── payUrl ──────────────────────│ │
│ │ │
│── return payUrl to frontend ─────────────────────────────────→│
│ │ User selects Alipay │
│ │←── submit payment ──────────│
│ │──→ return Alipay HTML Form ─│
│ │ │ redirect to Alipay
│ │ │ payment complete
│←── POST /payment/notify ─────── │ │
│ (async, server-to-server) │ │
│ │──→ redirect to successUrl ─→│
public String createInstallmentOrder(String orderId, long amountCents, String userId) {
var request = new PaymentRequest();
request.setMerchantOrderNum(orderId);
request.setAmount(amountCents); // e.g. 360000 = $3600.00
request.setCurrency("USD");
request.setPaymentType("unionpay");
request.setDescription("MacBook Pro 14 inch");
request.setNotifyUrl("https://your-domain.com/payment/notify");
request.setRedirectSuccessUrl("https://your-domain.com/order/" + orderId + "/success");
request.setRedirectErrorUrl("https://your-domain.com/order/" + orderId + "/failed");
request.setRedirectCallerUrl("https://your-domain.com/cart");
// Business extension code, comma-separated, max 256 chars
request.setExtraTradeCode("installment");
// Business data corresponding to each extraTradeCode, JSON format, max 1024 chars
request.setExtraTradeContent("{\"installment\":{\"periods\":12}}");
// Pass-through field: returned as-is in the payment notification callback
request.setAttach("{\"userId\":\"" + userId + "\",\"channel\":\"app\"}");
var response = oppClient.createPaymentUrl(request);
return response.getPayUrl();
}Pass a comma-separated list of merCodes to restrict the payment page to a specific subset.
The buyer chooses one merchant from this whitelist. All listed codes must be valid for the
company and currency — any invalid code returns code: 1058.
public String createMultiMerchantOrder(String orderId, long amountCents) {
var request = new PaymentRequest();
request.setMerchantOrderNum(orderId);
request.setAmount(amountCents);
request.setCurrency("HKD");
request.setMerchantCode("960105331000001,960105331000002"); // ← comma-separated whitelist
return oppClient.createPaymentUrl(request).getPayUrl();
}Only takes effect when your acquirer merchant is provisioned with subMerchantSupport=Y; ignored otherwise.
import com.dynamicpay.opp.sdk.model.SubMerItemDTO;
public String createUnionpaySplitOrder(String orderId, long amountCents) {
var request = new PaymentRequest();
request.setMerchantOrderNum(orderId);
request.setAmount(amountCents);
request.setCurrency("CNY");
request.setPaymentType("unionpay");
request.setSubMerAmount(java.util.Arrays.asList(
new SubMerItemDTO("M001", "A", 60000L),
new SubMerItemDTO("M002", "D", 40000L)
));
return oppClient.createPaymentUrl(request).getPayUrl();
}Merchant-initiated cancellation of an unpaid, not-yet-dispatched order:
import com.dynamicpay.opp.sdk.client.OppClient;
import com.dynamicpay.opp.sdk.model.RevokeRequest;
import com.dynamicpay.opp.sdk.model.RevokeResponse;
@Service
public class OrderCancelService {
@Autowired
private OppClient oppClient;
public void cancelOrder(String orderNum) {
RevokeRequest request = new RevokeRequest();
request.setOrderNum(orderNum);
request.setRevokeReason("Customer requested cancellation");
// companyId / applyServiceAccessType optional — defaults to SDK config
RevokeResponse response = oppClient.revokeOrder(request);
if (response.isSuccess()) {
System.out.println("Revoked at: " + response.getRevokeTime());
} else {
System.err.println("Revoke failed: code=" + response.getCode()
+ " message=" + response.getMessage());
}
}
}The server only revokes orders satisfying both conditions:
| Field | Required value | Meaning |
|---|---|---|
status |
0 |
Order is unpaid |
is_dispatched |
0 |
Order has not been forwarded to a downstream payment channel |
Paid orders or orders already dispatched cannot be revoked — use the refund flow instead.
Calling revokeOrder twice on the same already-revoked order returns:
{ "code": 0, "message": "Already revoked" }Safe to retry on network errors.
| Field | Type | Required | Description |
|---|---|---|---|
orderNum |
String | Yes | Platform-issued order number to revoke. Echoed in the URL path and the body — server cross-checks. |
companyId |
String | No | Defaults to opp.company-id from SDK config. Override per-call when the SDK serves multiple merchants. |
applyServiceAccessType |
String | No | opp (default) or billpay. Determines server-side verification key source. |
companyName |
String | Conditional | Required when applyServiceAccessType is billpay. Used by the OPP server to locate the billpay signing key. Ignored for the default opp channel. |
revokeReason |
String | No | Free-form audit note, max 256 chars. Persisted in opp_order.revoke_reason. |
privateKey |
String | No | Inline PEM private key for this call only. Same semantics as PaymentRequest.privateKey — useful for multi-merchant signing. Never sent in the HTTP body or signed content. |
| Field | Type | Description |
|---|---|---|
code |
int | 0 on success or idempotent already-revoked; see error code table below |
message |
String | Human-readable result |
revokeTime |
String | Server-side revoke timestamp (ISO-8601 LocalDateTime, UTC). Populated when an actual state change occurred. |
| Code | Meaning |
|---|---|
0 |
Success or already revoked (idempotent) |
1020 |
Timestamp outside allowed window |
1022 |
companyId missing |
1023 |
Signature verification failed |
1024 |
Merchant not authorized |
1054 |
orderNum mismatch between URL path and body (SDK guards against this internally) |
1055 |
Order not found |
1056 |
Order does not belong to this company |
1057 |
Order not in a revocable state (already paid or already dispatched) |
A successful revoke also triggers (server-side):
- Redis access key cleanup — invalidates any in-flight payment page session for this order.
- JWT blacklist entry — any already-issued JWT for this order is rejected by the OPP interceptor for the remainder of its 5-minute lifetime.
So even if a buyer holds an active JWT, they cannot complete payment after revoke.
| Field | Type | Required | Description |
|---|---|---|---|
merchantOrderNum |
String | Yes | Your unique order ID, max 32 chars |
amount |
long | Yes | Amount in smallest currency unit (cents) |
currency |
String | Yes | ISO 4217 currency code, e.g. USD |
paymentType |
String | No | alipay / wechat / unionpay / vmpay. Omit to show all options. Mastercard Click to Pay is triggered automatically by merchant configuration — no value needed. |
merchantCode |
String | No | Acquirer merchant code. Single value ("960105331000001") → single-merchant payment page. Comma-separated multi-value ("960105331000001,960105331000002") → multi-merchant payment page restricted to this whitelist; buyer picks one. All listed codes must be valid (any invalid code → code: 1058). Whitespace around commas tolerated, duplicates de-duplicated. Max 256 chars. Required when extraTradeCode is delegated. |
description |
String | No | Order description |
notifyUrl |
String | No | Server-to-server async notification URL |
redirectSuccessUrl |
String | No | Browser redirect URL after successful payment (appended to payUrl as query parameter) |
redirectErrorUrl |
String | No | Browser redirect URL after failed payment (appended to payUrl as query parameter) |
redirectCallerUrl |
String | No | Browser redirect URL when user clicks Back / Cancel (appended to payUrl as query parameter) |
extraTradeCode |
String | No | Comma-separated business codes, e.g. installment, max 256 chars |
extraTradeContent |
String | No | JSON map matching extraTradeCode keys, max 1024 chars |
attach |
String | No | Custom pass-through data, returned as-is in notification callback |
email |
String | No | Cardholder email. Click to Pay only. When provided with mobile, skips the identity page. |
mobile |
String | No | Cardholder mobile number in E.164 format (e.g. +85212345678). Click to Pay only. |
mobileCountryCode |
String | No | Mobile country dialing code, e.g. 852 (HK), 61 (AU), 1 (US/CA). Click to Pay only. |
subMerAmount |
List<SubMerItemDTO> |
No | Sub-merchant amount split, forwarded to UnionPay as sub_mer_amount. Each entry is {subMid, type, amount} (see below), max 20 entries. Only takes effect when the acquirer merchant is configured with subMerchantSupport=Y; otherwise ignored. |
firstName |
String | No | Cardholder first name. Optional, used to pre-fill name in Click to Pay. Max 100 chars. |
lastName |
String | No | Cardholder last name. Optional, used to pre-fill name in Click to Pay. Max 100 chars. |
isAdditional3DSData |
Integer | No | Enable additional 3DS data on this transaction. 1 = enable, 0 / omit = standard. |
applyServiceAccessType |
String | No | opp (default) or billpay. Determines server-side verification key source. |
companyId |
String | No | Defaults to opp.company-id from SDK config. Override per-call when the SDK serves multiple merchants. |
companyName |
String | Conditional | Required when applyServiceAccessType is billpay. Used by the OPP server to locate the billpay signing key. Ignored for the default opp channel. |
privateKey |
String | No | Inline PEM private key for this call only. Overrides the SDK's configured opp.private-key-path — useful when one SDK instance serves multiple merchants with different keys. Never sent in the HTTP body or signed content. |
⚠ Breaking change in 1.1.1:
subMerAmountwas a plainString(caller-formatted, max 1024 chars) through 1.1.0. From 1.1.1 it isList<SubMerItemDTO>instead — the old string form no longer compiles. If your code calledrequest.setSubMerAmount("..."), update it to build a list ofSubMerItemDTO(see below and Example 5).
| Field | Type | Required | Description |
|---|---|---|---|
subMid |
String | Yes | Sub-merchant ID, max 32 chars |
type |
String | Yes | Split type |
amount |
Long | Yes | Split amount in the smallest currency unit (cents), must be greater than 0 |
| Environment | API host | Hosted page host |
|---|---|---|
| Sandbox | https://uat-opp-api.dynamicg.com |
https://uat-opp.dynamicg.com |
| Production | https://opp-api.dynamicg.com |
https://opp.dynamicg.com |
Switch via opp.environment: sandbox or opp.environment: prod in application.yml. When opp.environment is omitted, the SDK defaults to prod (changed in 1.0.2) — always set this property explicitly to avoid accidentally hitting production. The URLs above are built-in defaults — no URL configuration is required for standard deployments.
For special cases (e.g. pointing at a local or staging server), you can override the defaults. Do not set these properties unless explicitly instructed by DynamicPay technical staff.
opp:
environment: sandbox
sandbox-url: https://staging-api.example.com # overrides sandbox API host
sandbox-page-url: https://staging-page.example.com # overrides sandbox page host
prod-url: https://my-custom-prod.example.com
prod-page-url: https://my-custom-opp-page.example.comMIT