AinePay
EN中文
API 参考

身份验证

所有 API 请求都需要请求签名密钥签名以及规定过期时间;AinePay 的 Webhook 回调会使用 Webhook 验证密钥进行签名。本页包含 API 请求签名规则和 Webhook 验签规则及对应的示例。

必需的请求头

  • x-api-key - 商户请求签名密钥
  • x-api-signature - 小写十六进制的 HMAC-SHA256 签名
  • x-api-timestamp - 客户端当前时间(毫秒)
  • x-api-recv-window - 客户端允许的时间偏移,通常 60000 毫秒,最大 180000 毫秒

所有请求均只支持 form 表单提交。

签名规则

  1. 收集 query string 参数和表单参数。
  2. 将所有参数展开为键值对,包括重复的键。
  3. 按键排序,然后按值排序,均为升序。
  4. 使用 UTF-8 进行 URL 编码。
  5. 附加 timestamprecvWindow
  6. 使用密钥计算 HMAC-SHA256。

请求签名示例

一个请求可以同时包含查询参数、表单参数、索引键和 URL 编码值。

  • 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

签名:

f10590f4f0f8fb2c63382276edf128061eb51b36ae5522d09d28376f03ee2d92

Java 请求签名示例

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 验证示例

回调字段在解析、排序和重建规范字符串后进行验证。

  • 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

规范字符串:

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

签名:

08fbca6b485a773c764ee0b5933e7936d536cda67bb44c19667a41ae401d7036

验证结果:true

Java Webhook 验证示例

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;
    }
}

常见错误

  • 参数签名顺序错误
  • 服务器时钟偏移导致时间戳被拒绝
  • 仅支持表单