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 表单提交。
签名规则
- 收集 query string 参数和表单参数。
- 将所有参数展开为键值对,包括重复的键。
- 按键排序,然后按值排序,均为升序。
- 使用 UTF-8 进行 URL 编码。
- 附加
timestamp和recvWindow。 - 使用密钥计算 HMAC-SHA256。
请求签名示例
一个请求可以同时包含查询参数、表单参数、索引键和 URL 编码值。
- Query string:
?orderIds[1]=222&orderIds[0]=111 - Form body:
coin=USDT_ERC20&userId=1000000001¬e=hello world - timestamp:
1761611071000 - recvWindow:
60000 - secretKey:
sv_5n61GLATPVKKejONbmwQPg2LuXZwRlibgDuoDLUQzV4
Payload:
coin=USDT_ERC20¬e=hello+world&orderIds%5B0%5D=111&orderIds%5B1%5D=222&userId=1000000001×tamp=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 + "×tamp=" + 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;
}
}常见错误
- 参数签名顺序错误
- 服务器时钟偏移导致时间戳被拒绝
- 仅支持表单