Skip to Content
ResourcesBest PracticesIntegration Best Practices

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:

  1. Business Failure: Transaction itself failed (e.g., insufficient balance, card declined), SDK will throw SUNBAYBusinessException
  2. 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 user
    • FAIL - Transaction failed, do not ship
    • CLOSED - 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.

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 shipping

If 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)

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
Last updated on