Integration Best Practices
This document introduces key considerations for merchants integrating SUNBAY API, helping you complete the integration smoothly and avoid common pitfalls.
Use Official SDKs
Strongly Recommended to Use Official SDKs
Using official SDKs can avoid manually handling complex logic such as authentication and error retries, greatly reducing integration difficulty and error probability.
Why Use SDKs?
- ✅ Automatic Authentication: You don’t need to manually handle API keys and authentication logic; the SDK handles it automatically
- ✅ Error Handling: SDK has already handled common network errors and timeouts
- ✅ Type Safety: IDE will prompt you with correct parameters, reducing typos
- ✅ Continuous Updates: SDK will follow API updates; you only need to upgrade versions
Considerations
- Reuse Client Instances: Don’t create a new client for each call; reuse the same instance
- Environment Separation: Use different API keys and configurations for development and production environments
See SDK Documentation for integration details.
Properly Handle API Responses
Distinguish Business Failures from System Errors
API calls may return two types of failures:
- Business Failure: Transaction itself failed (e.g., insufficient balance, card declined), SDK will throw
SUNBAYBusinessException - System Error: Network errors, timeouts, etc., SDK will throw
SUNBAYNetworkException
import com.sunmi.sunbay.nexus.exception.SUNBAYBusinessException;
import com.sunmi.sunbay.nexus.exception.SUNBAYNetworkException;
public SaleResponse processPayment(SaleRequest request) {
try {
SaleResponse response = client.sale(request);
// If no exception is thrown, API call succeeded (code = "0")
// Note: code = "0" only means interface call succeeded, not transaction success
// Transaction status needs to be determined by transactionStatus
return response;
} catch (SUNBAYBusinessException e) {
// Business failure: This is a normal business result, no retry needed
// Display appropriate message to user based on error code
String userMessage = getErrorMessage(e.getCode());
showErrorToUser(userMessage);
throw e;
} catch (SUNBAYNetworkException e) {
// System error: Possibly network issue, can consider retry
if (e.isRetryable()) {
// Can retry
log.warn("Network error, retryable: {}", e.getMessage());
} else {
// Non-retryable error, display to user directly
showErrorToUser("Payment service temporarily unavailable, please try again later");
}
throw e;
}
}Error Code Handling
In SUNBAYBusinessException, you can display appropriate messages to users based on error codes. Error codes are categorized as:
- S prefix: Security restriction errors (e.g., signature verification failed, IP whitelist)
- C prefix: Client errors (e.g., parameter errors, order status not allowed)
- M prefix: Merchant configuration errors (e.g., insufficient permissions, application not activated)
- P prefix: Payment channel errors (e.g., bank channel exception)
- E prefix: System errors (e.g., system busy, network timeout)
catch (SUNBAYBusinessException e) {
String code = e.getCode();
String message = e.getMessage(); // Use error message returned by API
// Display friendly message to user based on error code type
if (code.startsWith("C")) {
// Client error, usually parameter or business logic issue
message = "Request parameter error, please check and retry";
} else if (code.startsWith("M")) {
// Merchant configuration error, need to contact administrator
message = "Merchant configuration error, please contact technical support";
} else if (code.startsWith("P") || code.startsWith("E")) {
// Payment channel or system error, can prompt to retry later
message = "Payment service temporarily unavailable, please try again later";
}
showErrorToUser(message);
log.error("Business error: code={}, message={}", code, e.getMessage());
throw e;
}View Complete Error Code List
For detailed error code descriptions and solutions, please refer to Error Codes Documentation.
Transaction Status Handling
Understanding Transaction Status
Transaction statuses are divided into three categories:
- Initial State:
INITIAL- Transaction just created, not yet processed - Processing:
PROCESSING- Transaction is being processed, need to wait for result - Final States:
SUCCESS- Transaction successful, can ship to userFAIL- Transaction failed, do not shipCLOSED- Transaction closed (voided or refunded)
Important: Don’t Ship Immediately
Don’t Ship Immediately After Receiving API Response
Even if API returns success, wait for transaction to reach final state (SUCCESS) before shipping, as transaction may still be processing.
Recommended Approach: Use Webhook
Best Practice: Configure Webhook URL to let SUNBAY proactively notify you of transaction results, rather than frequent polling.
// Configure Webhook URL when creating transaction
SaleRequest request = SaleRequest.builder()
.amount(amount)
.currency("USD")
.notifyUrl("https://your-domain.com/webhook/payment") // Your Webhook address
.build();
SaleResponse response = client.sale(request);
// Don't ship immediately at this point!
// Wait for Webhook notification that transaction status becomes SUCCESS before shippingIf You Must Query Status
If you cannot use Webhook for some reason and need to query transaction status:
Don’t Poll Frequently
Frequent polling (e.g., once per second) wastes resources and may trigger rate limiting. Using a delay queue to handle status queries is more recommended.
Recommended Approach: Use Delay Queue
Using a delay queue can avoid blocking current requests and flexibly control query timing:
// Use delay queue to query transaction status
@Service
public class TransactionStatusChecker {
@Autowired
private DelayQueue<TransactionQueryTask> delayQueue;
/**
* Submit transaction status query task to delay queue
*/
public void scheduleStatusCheck(String transactionId) {
// First query: after 2 seconds
delayQueue.offer(new TransactionQueryTask(transactionId, 2000));
}
/**
* Process query tasks in delay queue
*/
@Scheduled(fixedDelay = 1000) // Check queue every second
public void processStatusCheck() {
TransactionQueryTask task = delayQueue.poll();
if (task == null) {
return;
}
try {
QueryResponse response = client.query(
QueryRequest.builder()
.transactionId(task.getTransactionId())
.build()
);
String status = response.getData().getTransactionStatus();
if (isFinalStatus(status)) {
// Reached final state, process result
handleFinalStatus(task.getTransactionId(), status);
} else {
// Still processing, continue delayed query (exponential backoff)
long nextDelay = task.getNextDelay(); // 2s → 4s → 8s → 16s
if (nextDelay <= 60000) { // Wait up to 60 seconds
delayQueue.offer(new TransactionQueryTask(
task.getTransactionId(),
nextDelay
));
} else {
// Timeout, log or alert
log.warn("Transaction status check timeout: {}", task.getTransactionId());
}
}
} catch (Exception e) {
log.error("Failed to check transaction status", e);
}
}
private boolean isFinalStatus(String status) {
return "SUCCESS".equals(status) ||
"FAIL".equals(status) ||
"CLOSED".equals(status);
}
}
// Delay task
class TransactionQueryTask implements Delayed {
private final String transactionId;
private final long executeTime;
private final int retryCount;
public TransactionQueryTask(String transactionId, long delayMs) {
this.transactionId = transactionId;
this.executeTime = System.currentTimeMillis() + delayMs;
this.retryCount = 0;
}
public TransactionQueryTask(String transactionId, long delayMs, int retryCount) {
this.transactionId = transactionId;
this.executeTime = System.currentTimeMillis() + delayMs;
this.retryCount = retryCount;
}
public long getNextDelay() {
// Exponential backoff: 2s → 4s → 8s → 16s → 32s
return (long) Math.pow(2, retryCount + 1) * 1000;
}
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(executeTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
@Override
public int compareTo(Delayed o) {
return Long.compare(this.executeTime, ((TransactionQueryTask) o).executeTime);
}
// getters...
}Alternative Approach: Fixed Interval Polling
If you cannot use a delay queue, you can use fixed interval polling (not recommended):
// Not recommended: Fixed interval polling blocks threads
public TransactionDetail waitForResult(String transactionId) {
long[] intervals = {2000, 4000, 8000, 16000}; // 2s, 4s, 8s, 16s
for (long interval : intervals) {
try {
Thread.sleep(interval);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Interrupted", e);
}
QueryResponse response = client.query(
QueryRequest.builder()
.transactionId(transactionId)
.build()
);
String status = response.getData().getTransactionStatus();
if (isFinalStatus(status)) {
return response.getData();
}
}
throw new TimeoutException("Transaction timeout, please check manually");
}Webhook Integration Considerations
Must Verify Signature and Key Fields
All Webhook Requests Must Verify Signature and Validate Key Business Parameters
Verifying signature alone is not enough; you also need to validate key fields such as transaction amount, currency, and merchant order ID match your local order, otherwise tampering may lead to financial risks.
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import javax.servlet.http.HttpServletRequest;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.BufferedReader;
import java.nio.charset.StandardCharsets;
import java.util.Map;
@RestController
@RequestMapping("/webhook")
public class WebhookController {
// Signature key obtained from SUNBAY Copilot application details
private static final String WEBHOOK_SECRET = "your_webhook_secret_key";
@PostMapping("/notify")
public ResponseEntity<Map<String, String>> handleWebhook(HttpServletRequest request) {
try {
// 1. Get request headers
String signature = request.getHeader("X-Signature");
String requestId = request.getHeader("X-Client-Request-Id");
String timestamp = request.getHeader("X-Timestamp");
// 2. Read raw request body (don't deserialize)
String payload = getRequestBody(request);
// 3. Verify signature (must!)
if (!verifySignature(payload, signature, WEBHOOK_SECRET)) {
log.error("Invalid webhook signature");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("code", "INVALID_SIGNATURE", "message", "Signature verification failed"));
}
// 4. Verify timeliness (optional, prevent replay attacks)
if (!verifyTimestamp(timestamp)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("code", "EXPIRED", "message", "Request expired"));
}
// 5. Parse request body and validate key fields (amount, currency, order ID, etc.)
WebhookNotifyRequest notifyRequest = JSON.parseObject(payload, WebhookNotifyRequest.class);
// Load original order from local system
Order order = orderService.findByReferenceOrderId(notifyRequest.getReferenceOrderId());
if (order == null) {
log.error("Order not found for referenceOrderId: {}", notifyRequest.getReferenceOrderId());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("code", "ORDER_NOT_FOUND", "message", "Order not found"));
}
// Validate amount and currency match
if (!order.getCurrency().equals(notifyRequest.getPriceCurrency()) ||
!order.getAmount().equals(notifyRequest.getOrderAmount())) {
log.error("Webhook amount mismatch. localAmount={}, localCurrency={}, webhookAmount={}, webhookCurrency={}",
order.getAmount(), order.getCurrency(),
notifyRequest.getOrderAmount(), notifyRequest.getPriceCurrency());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("code", "AMOUNT_MISMATCH", "message", "Amount mismatch"));
}
// 6. Process business logic (idempotent handling)
processWebhook(notifyRequest, requestId);
// 7. Return success response
return ResponseEntity.ok(Map.of("code", "SUCCESS", "message", "Received"));
} catch (Exception e) {
log.error("Failed to process webhook", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("code", "ERROR", "message", "Internal error"));
}
}
/**
* Read raw request body
*/
private String getRequestBody(HttpServletRequest request) throws Exception {
StringBuilder sb = new StringBuilder();
try (BufferedReader reader = request.getReader()) {
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
}
return sb.toString();
}
/**
* Verify signature
*/
private boolean verifySignature(String payload, String signature, String secret) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKeySpec = new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8),
"HmacSHA256"
);
mac.init(secretKeySpec);
byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
String expectedSignature = bytesToHex(hash);
return expectedSignature.equalsIgnoreCase(signature);
} catch (Exception e) {
log.error("Signature verification error", e);
return false;
}
}
/**
* Convert byte array to hexadecimal string
*/
private String bytesToHex(byte[] bytes) {
StringBuilder result = new StringBuilder();
for (byte b : bytes) {
result.append(String.format("%02x", b));
}
return result.toString();
}
/**
* Verify timeliness (optional)
*/
private boolean verifyTimestamp(String timestamp) {
try {
long requestTime = Long.parseLong(timestamp);
long currentTime = System.currentTimeMillis();
// Only accept requests within 5 minutes
return Math.abs(currentTime - requestTime) <= 5 * 60 * 1000;
} catch (Exception e) {
return false;
}
}
/**
* Process business logic (idempotent handling)
*/
private void processWebhook(WebhookNotifyRequest request, String requestId) {
// Check if already processed (idempotent)
if (isAlreadyProcessed(request.getTransactionId())) {
log.info("Transaction already processed: {}", request.getTransactionId());
return;
}
// Execute business logic
updateOrderStatus(request);
// Mark as processed
markAsProcessed(request.getTransactionId());
}
}Return Response Quickly
Webhook Processing Must Return 200 Response Within 5 Seconds
If timeout occurs, SUNBAY will consider notification failed and retry, which may lead to duplicate processing.
Recommended Approach: Return 200 immediately, then process business logic asynchronously.
@PostMapping("/webhook/notify")
public ResponseEntity<Map<String, String>> handleWebhook(HttpServletRequest request) {
// Verify signature
String payload = getRequestBody(request);
String signature = request.getHeader("X-Signature");
if (!verifySignature(payload, signature, WEBHOOK_SECRET)) {
return ResponseEntity.status(401)
.body(Map.of("code", "INVALID_SIGNATURE", "message", "Invalid signature"));
}
// Parse request body
WebhookNotifyRequest notifyRequest = JSON.parseObject(payload, WebhookNotifyRequest.class);
// Return 200 immediately, then process business logic asynchronously
executorService.submit(() -> {
// Async processing: update order status, ship, etc.
processWebhook(notifyRequest, request.getHeader("X-Client-Request-Id"));
});
return ResponseEntity.ok(Map.of("code", "SUCCESS", "message", "Received")); // Quick return
}Prevent Duplicate Processing
Webhooks may be sent repeatedly (e.g., retries due to network issues), you must implement idempotency control.
// Use transaction ID or request ID to prevent duplicate processing
private final Set<String> processedTransactions = new HashSet<>();
public void processWebhook(WebhookNotifyRequest request, String requestId) {
// Use transactionId or X-Client-Request-Id as idempotency key
String idempotencyKey = request.getTransactionId(); // or use requestId
// Check if already processed
if (processedTransactions.contains(idempotencyKey)) {
log.info("Transaction already processed: {}", idempotencyKey);
return; // Already processed, return directly
}
// Process event
updateOrderStatus(request);
// Mark as processed
processedTransactions.add(idempotencyKey);
// Recommended: Persist to database to avoid loss after service restart
}Error Handling and Retry
When Should You Retry?
Important: Payment transaction interfaces should not automatically retry; only after merchant system implements idempotency control can it decide whether to retry.
SDK only automatically retries network for idempotent GET requests (such as query interfaces). Payment interfaces like sale will not automatically retry to avoid duplicate charges.
Scenarios Where Retry is Appropriate (usually for query interfaces):
- Network connection errors
- Request timeouts
- Server returns 5xx errors
Scenarios Where Retry is Not Appropriate:
- Automatically retrying payment/refund transaction interfaces in uncertain states (easy to cause duplicate payments)
- Business failures (e.g., insufficient balance, card declined)
- Authentication failures (API key error)
- Parameter errors (e.g., amount format error)
Retry Strategy for Query Interfaces (Recommended)
For query interfaces like query, if network errors occur, you can use exponential backoff retry; for payment interfaces, it’s recommended to confirm status through “query + Webhook” rather than directly retrying payment requests.
import com.sunmi.sunbay.nexus.exception.SUNBAYNetworkException;
public QueryResponse queryWithRetry(QueryRequest request) {
int maxRetries = 3;
long delay = 1000; // Initial delay 1 second
for (int i = 0; i < maxRetries; i++) {
try {
return client.query(request);
} catch (SUNBAYNetworkException e) {
// Determine if should retry
if (!e.isRetryable() || i == maxRetries - 1) {
throw e; // Don't retry or reached max retries
}
// Wait then retry
try {
Thread.sleep(delay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException("Retry interrupted", ie);
}
delay *= 2; // Exponential backoff: 1s → 2s → 4s
log.info("Retrying query, attempt {}/{}", i + 2, maxRetries);
}
}
throw new RuntimeException("Max retries exceeded");
}Testing Considerations
Use Sandbox Environment
During development, be sure to use sandbox environment for testing, don’t test in production environment.
// Sandbox environment configuration
NexusClient client = new NexusClient.Builder()
.apiKey("sandbox_api_key") // Sandbox environment key
.apiSecret("sandbox_api_secret")
.baseUrl("https://sandbox-api.sunbay.com") // Sandbox environment address
.build();Testing Points
When testing, ensure coverage of the following scenarios:
- ✅ Success Scenario: Normal payment flow
- ✅ Failure Scenarios: Insufficient balance, card declined, etc.
- ✅ Timeout Scenario: Handling network timeouts
- ✅ Webhook Scenario: Receiving and processing Webhook notifications
- ✅ Duplicate Notifications: Handling duplicate Webhook sends
Integration Checklist
Development Phase
- Use official SDK for integration
- Properly distinguish business failures from system errors
- Configure Webhook URL (must be HTTPS)
- Implement Webhook signature verification
- Implement idempotency control (prevent duplicate processing)
- Complete all testing in sandbox environment
Before Going Live
- Switch to production environment API keys
- Confirm Webhook URL is accessible (HTTPS)
- Test Webhook reception and processing
- Confirm error handling logic is correct
- Confirm not shipping in PROCESSING status
- Set up monitoring and alerts
After Going Live
- Monitor API call success rate
- Monitor Webhook reception
- Check for duplicate shipping situations
- Regularly check error logs