AinePay
EN中文
API Reference

Authentication

All API requests must be signed with the request signing key and include an expiration window. AinePay signs webhook callbacks with the webhook verification key. This page covers the API request signing rules, webhook verification rules, and their corresponding examples.

Required headers

  • x-api-key - Merchant request signing key
  • x-api-signature - HMAC-SHA256 signature in lowercase hex
  • x-api-timestamp - client side current time in milliseconds
  • x-api-recv-window - allowed client time offset, usually 60000 milliseconds, maximum 180000 milliseconds

All requests only support form submission.

Signing rules

  1. Collect query string parameters and form parameters.
  2. Flatten all parameters into key-value pairs, including repeated keys.
  3. Sort by key, then by value, both in ascending order.
  4. URL-encode them using UTF-8.
  5. Append timestamp and recvWindow.
  6. Use the secret key and compute HMAC-SHA256.

Request Signing Example

A request can include query parameters, form parameters, indexed keys, and URL-encoded values together.

  • Query string: ?orderIds[1]=222&orderIds[0]=111
  • Form body: coin=USDT_ERC20&userId=1000000001&note=hello world
  • timestamp: 1761611071000
  • recvWindow: 60000
  • secretKey: sv_5n61GLATPVKKejONbmwQPg2LuXZwRlibgDuoDLUQzV4

Payload:

coin=USDT_ERC20&note=hello+world&orderIds%5B0%5D=111&orderIds%5B1%5D=222&userId=1000000001&timestamp=1761611071000&recvWindow=60000

Signature:

f10590f4f0f8fb2c63382276edf128061eb51b36ae5522d09d28376f03ee2d92

Java request signing example

package util;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class AinePaySigner {

    public static final class Params {
        private final Map<String, String[]> data = new LinkedHashMap<>();

        private Params() {}

        public static Params create() {
            return new Params();
        }

        public Params put(String key, String... values) {
            data.put(key, values == null ? new String[]{""} : values);
            return this;
        }
    }

    public static String sign(Params params, String timestamp, String recvWindow, String secretKey) throws Exception {
        List<String[]> pairs = new ArrayList<>();
        for (Map.Entry<String, String[]> e : params.data.entrySet()) {
            if (e.getValue() != null) {
                for (String v : e.getValue()) {
                    pairs.add(new String[]{e.getKey(), v == null ? "" : v});
                }
            }
        }

        pairs.sort(Comparator.<String[], String>comparing(p -> p[0]).thenComparing(p -> p[1]));

        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < pairs.size(); i++) {
            if (i > 0) sb.append("&");
            sb.append(URLEncoder.encode(pairs.get(i)[0], StandardCharsets.UTF_8.name()))
              .append("=")
              .append(URLEncoder.encode(pairs.get(i)[1], StandardCharsets.UTF_8.name()));
        }

        String payload = sb.length() == 0
                ? "timestamp=" + timestamp + "&recvWindow=" + recvWindow
                : sb + "&timestamp=" + timestamp + "&recvWindow=" + recvWindow;

        String b64url = secretKey.startsWith("sv_") ? secretKey.substring(3) : secretKey;
        byte[] secret = Base64.getUrlDecoder().decode(b64url);

        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret, "HmacSHA256"));
        byte[] digest = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));

        StringBuilder hex = new StringBuilder();
        for (byte b : digest) {
            hex.append(String.format("%02x", b));
        }
        return hex.toString();
    }
}

Webhook Verification Example

Callback fields are verified after parsing, sorting, and rebuilding the canonical string.

  • Raw callback body: userId=user_001&orderId=order123&status=PAID&merchantId=1001&coin=USDT&qty=1.11&expired=1767793122000&created=1767793122000&updated=1767793122000
  • notifySecret: sv_5n61GLATPVKKejONbmwQPg2LuXZwRlibgDuoDLUQzV4

Canonical string:

coin=USDT&created=1767793122000&expired=1767793122000&merchantId=1001&orderId=order123&qty=1.11&status=PAID&updated=1767793122000&userId=user_001

Signature:

08fbca6b485a773c764ee0b5933e7936d536cda67bb44c19667a41ae401d7036

Verification result: true

Java webhook verification example

package util;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;

public class AinePayVerifier {

    public static boolean verify(String rawBody, String signature, String notifySecret) throws Exception {
        Map<String, String> fields = parseBody(rawBody);
        String canonical = toCanonical(fields);

        String b64 = notifySecret.startsWith("sv_") ? notifySecret.substring(3) : notifySecret;
        byte[] secret = Base64.getUrlDecoder().decode(b64);

        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret, "HmacSHA256"));
        byte[] digest = mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8));
        String expected = toHex(digest);

        return constantTimeEquals(expected, signature == null ? "" : signature.toLowerCase());
    }

    public static Map<String, String> parseBody(String rawBody) throws Exception {
        Map<String, String> map = new LinkedHashMap<>();
        if (rawBody == null || rawBody.isEmpty()) return map;
        for (String pair : rawBody.split("&")) {
            int idx = pair.indexOf('=');
            if (idx < 0) continue;
            String key = URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8.name());
            String value = URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8.name());
            map.put(key, value);
        }
        return map;
    }

    public static String toCanonical(Map<String, String> fields) throws Exception {
        Map<String, String> sorted = new TreeMap<>(fields);
        StringBuilder sb = new StringBuilder();
        boolean first = true;
        for (Map.Entry<String, String> e : sorted.entrySet()) {
            if (!first) sb.append("&");
            first = false;
            sb.append(URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8.name()))
              .append("=")
              .append(URLEncoder.encode(e.getValue() == null ? "" : e.getValue(), StandardCharsets.UTF_8.name()));
        }
        return sb.toString();
    }

    private static String toHex(byte[] bytes) {
        char[] hex = "0123456789abcdef".toCharArray();
        char[] out = new char[bytes.length * 2];
        for (int i = 0, j = 0; i < bytes.length; i++) {
            int v = bytes[i] & 0xFF;
            out[j++] = hex[v >>> 4];
            out[j++] = hex[v & 0x0F];
        }
        return new String(out);
    }

    private static boolean constantTimeEquals(String a, String b) {
        if (a.length() != b.length()) return false;
        int r = 0;
        for (int i = 0; i < a.length(); i++) r |= (a.charAt(i) ^ b.charAt(i));
        return r == 0;
    }
}

Common mistakes

  • Signing parameters in the wrong order
  • Server clock drift causing timestamp rejection
  • Only form submission is supported