Appearance
回调(Webhook)
平台把业务事件以 HTTPS POST 推送到你注册的回调地址。每条回调都用平台的 Ed25519 私钥签名,你方用平台公钥验签。投递至少一次(可能重复),失败自动重试。
事件类型
| 事件 | 触发时机 | data |
|---|---|---|
TRANSACTION_CREATED | 新交易产生:发现充值(CONFIRMING)、提现提交(SUBMITTED)、站内转账创建(通常直接 COMPLETED) | 交易对象 |
TRANSACTION_STATUS_UPDATED | 交易状态变化:充值入账/未入账/转人工/失败,提现各阶段,待审批的站内转账被批准或拒绝 | 交易对象 |
WALLET_CREATED | 钱包地址分配完成(状态变为 ACTIVE) | { walletId, chainFamily, address } |
DEPOSIT_REVERSED | 已入账的充值因链重组被冲正 | { depositId },见 充值 · 链重组冲正 |
MANUAL_DEPOSIT_CREDITED | 平台人工补入账(不支持的代币、申诉找回等) | { caseId, walletId, assetId, amountRaw },见 充值 · 人工补入账 |
VAULT_GAS_LOW | 你的 Gas 钱包余额低于建议值,归集将排队等待(同一网络最多每 6 小时一次) | { networkKey, address, assetId, balanceRaw, recommendedMinRaw } |
WEBHOOK_TEST | 调用测试接口 | { message: "test event from WaaS", endpointId } |
交易事件的 data 是投递时的快照
TRANSACTION_CREATED / TRANSACTION_STATUS_UPDATED 的 data 是投递那一刻查询到的最新交易对象。延迟或重试的事件可能携带比事件本身更新的状态(例如 TRANSACTION_CREATED 里已经是 COMPLETED),同一交易的多个事件也可能内容相同。处理时请以 data.status 为准、按状态机幂等推进,不要依赖事件类型或到达顺序。
载荷格式
http
POST /your/webhook/path HTTP/1.1
Content-Type: application/json
X-WaaS-Event-Id: 0192a4ca-1000-7000-8000-000000000002
X-WaaS-Event-Type: TRANSACTION_STATUS_UPDATED
X-WaaS-Timestamp: 1791010995
X-WaaS-Signature: keyId=v1,sig=5e0f…(128 个十六进制字符)json
{
"eventId": "0192a4ca-1000-7000-8000-000000000002",
"type": "TRANSACTION_STATUS_UPDATED",
"tenantId": "0191f0aa-1b2c-7d3e-8f40-5a6b7c8d9e0f",
"createdAt": "2026-10-03T06:03:15.400000Z",
"data": { "id": "0192a4c9-2f00-7aaa-8bbb-ccccdddd0001", "kind": "DEPOSIT", "status": "COMPLETED", "…": "交易对象的其余字段" }
}| 字段 / 头 | 说明 |
|---|---|
eventId / X-WaaS-Event-Id | 事件唯一 ID(两者相同)。用于去重 |
type / X-WaaS-Event-Type | 事件类型 |
tenantId | 你的租户 ID |
createdAt | 事件产生时间(RFC 3339) |
data | 事件数据,见上表 |
X-WaaS-Timestamp | 本次投递的发送时间(Unix 秒)。每次重试都会更新 |
X-WaaS-Signature | keyId=v1,sig=<hex>:对 "<X-WaaS-Timestamp>.<原始请求体>" 的 Ed25519 签名(64 字节,hex 编码) |
完整的交易事件示例见 充值 · 回调示例。
签名验证
- 获取平台回调公钥:
GET /v1/webhooks/public_key,返回{"keyId":"v1","publicKey":"<64 位 hex>"}。公钥可以缓存;keyId用于将来轮换。 - 取原始请求体字节(不要先解析 JSON 再序列化),拼接
message = X-WaaS-Timestamp + "." + rawBody。 - 从
X-WaaS-Signature中解析keyId与sig,确认keyId = v1,用公钥对message验证sig。 - 建议同时检查
X-WaaS-Timestamp与当前时间相差不超过 5 分钟,防止重放。 - 验签失败返回
401(平台会按计划重试);验签成功后,先按eventId去重并持久化,再返回 2xx,然后异步处理业务。
下面的示例都已用平台签名格式验证(正确签名返回 true,请求体被改动一个字节即返回 false)。
Node.js
js
import { createPublicKey, verify } from "node:crypto";
// 平台回调公钥:GET /v1/webhooks/public_key 返回的 publicKey(hex,32 字节)
const WAAS_WEBHOOK_PUBLIC_KEY_HEX = process.env.WAAS_WEBHOOK_PUBLIC_KEY_HEX;
const PUBLIC_KEY = createPublicKey({
key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), Buffer.from(WAAS_WEBHOOK_PUBLIC_KEY_HEX, "hex")]),
format: "der",
type: "spki",
});
/** rawBody: 原始请求体 Buffer(不要先 JSON.parse 再序列化);headers: 小写键的请求头对象 */
export function verifyWebhook(rawBody, headers, toleranceSec = 300) {
const ts = headers["x-waas-timestamp"];
const parts = Object.fromEntries(String(headers["x-waas-signature"] ?? "").split(",").map((kv) => kv.trim().split("=")));
if (!ts || parts.keyId !== "v1" || !/^[0-9a-f]{128}$/.test(parts.sig ?? "")) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec) return false; // 防重放(可选但推荐)
const message = Buffer.concat([Buffer.from(`${ts}.`), rawBody]);
return verify(null, message, PUBLIC_KEY, Buffer.from(parts.sig, "hex"));
}
// 用法(Node 内置 http;Express 请用 express.raw({ type: "application/json" }) 拿到 Buffer)
import http from "node:http";
http.createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const raw = Buffer.concat(chunks);
if (!verifyWebhook(raw, req.headers)) return res.writeHead(401).end();
const event = JSON.parse(raw.toString("utf8"));
// 按 event.eventId 去重并落库后再返回 200
res.writeHead(200).end("ok");
});
}).listen(8080);Python
python
import os
import time
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
# 平台回调公钥:GET /v1/webhooks/public_key 返回的 publicKey(hex)
PUBLIC_KEY = Ed25519PublicKey.from_public_bytes(bytes.fromhex(os.environ["WAAS_WEBHOOK_PUBLIC_KEY_HEX"]))
def verify_webhook(raw_body: bytes, timestamp: str, signature_header: str, tolerance_sec: int = 300) -> bool:
"""raw_body: 原始请求体字节;timestamp: X-WaaS-Timestamp;signature_header: X-WaaS-Signature"""
try:
parts = dict(p.strip().split("=", 1) for p in signature_header.split(","))
if parts.get("keyId") != "v1" or abs(time.time() - int(timestamp)) > tolerance_sec:
return False
PUBLIC_KEY.verify(bytes.fromhex(parts["sig"]), timestamp.encode() + b"." + raw_body)
return True
except (ValueError, KeyError, InvalidSignature):
return False
# Flask 用法
# @app.post("/waas/webhook")
# def waas_webhook():
# raw = request.get_data() # 原始字节
# if not verify_webhook(raw, request.headers.get("X-WaaS-Timestamp", ""), request.headers.get("X-WaaS-Signature", "")):
# return "bad signature", 401
# event = json.loads(raw)
# ... # 按 event["eventId"] 去重并落库
# return "ok", 200Go
go
package waaswebhook
import (
"crypto/ed25519"
"encoding/hex"
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
)
// VerifyWebhook 校验回调签名。pub = GET /v1/webhooks/public_key 返回的 publicKey 解码后的 32 字节。
func VerifyWebhook(pub ed25519.PublicKey, rawBody []byte, timestamp, sigHeader string, tolerance time.Duration) bool {
parts := map[string]string{}
for _, kv := range strings.Split(sigHeader, ",") {
if k, v, ok := strings.Cut(strings.TrimSpace(kv), "="); ok {
parts[k] = v
}
}
if parts["keyId"] != "v1" {
return false
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > tolerance.Seconds() {
return false
}
sig, err := hex.DecodeString(parts["sig"])
if err != nil || len(sig) != ed25519.SignatureSize {
return false
}
msg := append([]byte(timestamp+"."), rawBody...)
return ed25519.Verify(pub, msg, sig)
}
// 示例 handler:先验签,再按 X-WaaS-Event-Id 去重,最后返回 2xx。
func webhookHandler(pub ed25519.PublicKey) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil || !VerifyWebhook(pub, raw, r.Header.Get("X-WaaS-Timestamp"), r.Header.Get("X-WaaS-Signature"), 5*time.Minute) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
// eventID := r.Header.Get("X-WaaS-Event-Id") —— 已处理过则直接返回 200
// ... 解析 raw 并入库处理 ...
w.WriteHeader(http.StatusOK)
}
}Java
java
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.Map;
public class WebhookVerifier {
private final PublicKey publicKey;
/** publicKeyHex = GET /v1/webhooks/public_key 返回的 publicKey(32 字节 hex) */
public WebhookVerifier(String publicKeyHex) throws Exception {
byte[] spki = HexFormat.of().parseHex("302a300506032b6570032100" + publicKeyHex); // Ed25519 SPKI 前缀 + 原始公钥
this.publicKey = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(spki));
}
/** rawBody: 原始请求体字节;timestamp: X-WaaS-Timestamp;signatureHeader: X-WaaS-Signature */
public boolean verify(byte[] rawBody, String timestamp, String signatureHeader, long toleranceSec) {
try {
Map<String, String> parts = new HashMap<>();
for (String kv : signatureHeader.split(",")) {
String[] p = kv.trim().split("=", 2);
if (p.length == 2) parts.put(p[0], p[1]);
}
if (!"v1".equals(parts.get("keyId")) || parts.get("sig") == null) return false;
long ts = Long.parseLong(timestamp);
if (Math.abs(System.currentTimeMillis() / 1000 - ts) > toleranceSec) return false;
byte[] prefix = (timestamp + ".").getBytes(StandardCharsets.US_ASCII);
byte[] msg = new byte[prefix.length + rawBody.length];
System.arraycopy(prefix, 0, msg, 0, prefix.length);
System.arraycopy(rawBody, 0, msg, prefix.length, rawBody.length);
Signature v = Signature.getInstance("Ed25519");
v.initVerify(publicKey);
v.update(msg);
return v.verify(HexFormat.of().parseHex(parts.get("sig")));
} catch (Exception e) {
return false;
}
}
}投递与重试
- 投递成功的判定:你的服务在 10 秒内返回任意
2xx状态码。其余状态码、超时、连接失败都算失败。 - 失败后的重试间隔:1 分钟、5 分钟、30 分钟、2 小时、12 小时。首次投递加 5 次重试共 6 次,全部失败后该投递标记为
DEAD,不再自动重试(可调用 重发)。 - 每个回调端点独立投递、独立重试;不保证事件顺序。
- 端点停用(
DELETE或PATCH active=false)后不再投递;停用期间产生的事件不会补发给它,请用 对账 补齐。 - 生产环境要求回调地址为
https://,且不能解析到内网、回环或链路本地地址(会被拒绝投递,lastError中可见原因)。 - 你的处理应当幂等:同一事件可能因超时重试、手动重发而收到多次,用
X-WaaS-Event-Id去重。
对账建议
回调用于实时性,不应作为唯一的数据来源:
- 处理回调时以交易
id为唯一键落库,并以data.status更新状态;已到终态的交易忽略非终态事件(终态之后只有充值COMPLETED → REVERSED一种变化,见 交易状态)。 - 每隔几分钟用
GET /v1/transactions?createdFrom=<上次水位-重叠窗口>&limit=200翻页拉取,补齐漏收的事件;对仍处于非终态的交易用GET /v1/transactions/{id}刷新。 - 每日按
kind、assetId、status = COMPLETED汇总,与你方账务核对;必要时与GET /v1/users/{customerRefId}/balances抽样比对。 - 定期查看
GET /v1/webhooks/deliveries?status=DEAD,排查并重发失败事件。
管理接口
以下接口都需要 签名认证(GET /v1/webhooks/event_types 除外)。也可以在租户后台的“回调”页面操作。
回调端点对象
| 字段 | 类型 | 说明 |
|---|---|---|
id | string (UUID) | 端点 ID |
url | string | 回调地址 |
events | string[] | 订阅的事件类型;空数组 = 全部事件 |
description | string | null | 备注 |
status | string | ACTIVE / DISABLED |
createdAt | string | 创建时间 |
注册回调端点
http
POST /v1/webhooks请求体
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
url | string | 是 | 回调地址,≤ 2048 字符,不能含空白或 user:pass@ 凭据;生产环境必须 https:// |
events | string[] | 否 | 订阅的事件类型(见 事件类型),省略或空数组 = 全部 |
description | string | 否 | 备注 |
json
{ "url": "https://exchange.example.com/waas/webhook", "events": ["TRANSACTION_CREATED", "TRANSACTION_STATUS_UPDATED"], "description": "prod" }响应 201 Created:回调端点对象
json
{
"id": "0192a4b0-aaaa-7bbb-8ccc-dddd00000001",
"url": "https://exchange.example.com/waas/webhook",
"events": ["TRANSACTION_CREATED", "TRANSACTION_STATUS_UPDATED"],
"description": "prod",
"status": "ACTIVE",
"createdAt": "2026-10-03T04:50:00.000000Z"
}常见错误
| HTTP | code | 场景 |
|---|---|---|
| 422 | BAD_REQUEST | invalid url;webhook url must use https;webhook url must start with https://;webhook url must not contain credentials;unknown event type … |
| 422 | LIMIT_EXCEEDED | 已有 10 个 ACTIVE 端点(at most 10 active webhook endpoints) |
本接口不幂等,重复调用会创建多个端点。
回调端点列表
http
GET /v1/webhooks响应 200 OK:回调端点对象 数组(含已停用的),按创建时间倒序。
修改回调端点
http
PATCH /v1/webhooks/{id}请求体(字段都可选,未提供的保持不变)
| 字段 | 类型 | 说明 |
|---|---|---|
url | string | 新地址,校验规则同注册 |
events | string[] | 新的订阅列表(空数组 = 全部事件) |
description | string | 备注 |
active | boolean | true 启用,false 停用 |
json
{ "active": false }响应 200 OK:修改后的 回调端点对象。常见错误:422 BAD_REQUEST(同注册)、404 NOT_FOUND(webhook endpoint not found)。
删除(停用)回调端点
http
DELETE /v1/webhooks/{id}把端点状态设为 DISABLED(记录保留,可用 PATCH {"active": true} 重新启用)。
响应 204 No Content。常见错误:404 NOT_FOUND(webhook not found)。
发送测试事件
http
POST /v1/webhooks/{id}/test向该端点投递一条 WEBHOOK_TEST 事件(只发给这个端点,走正常签名与重试流程)。无请求体。端点为 DISABLED 时测试事件会等到重新启用后才投递。
响应 202 Accepted
json
{ "eventId": "0192a4b1-0000-7000-8000-000000000010" }常见错误:404 NOT_FOUND(webhook endpoint not found)。
投递记录
http
GET /v1/webhooks/deliveries?endpointId=<uuid>&status=DEAD&limit=50&before=<id>查询参数
| 参数 | 类型 | 说明 |
|---|---|---|
endpointId | UUID | 只看某个端点 |
status | string | PENDING(待投递/等待重试)、DELIVERED、DEAD |
limit | integer | 1–200,默认 50 |
before | integer | 翻页:上一页最后一条的 id |
响应 200 OK
json
{
"items": [
{
"id": 18231,
"eventId": "0192a4ca-1000-7000-8000-000000000002",
"eventType": "TRANSACTION_STATUS_UPDATED",
"endpointId": "0192a4b0-aaaa-7bbb-8ccc-dddd00000001",
"url": "https://exchange.example.com/waas/webhook",
"status": "PENDING",
"attempt": 2,
"responseStatus": 502,
"lastError": "HTTP 502 Bad Gateway",
"durationMs": 133,
"lastAttemptAt": "2026-10-03T06:09:16.002000Z",
"nextAt": "2026-10-03T06:39:16.002000Z"
}
]
}| 字段 | 说明 |
|---|---|
id | 投递记录 ID(整数,用于翻页) |
attempt | 已尝试次数 |
responseStatus | 最近一次你方返回的 HTTP 状态码;连接失败时为 null |
lastError | 最近一次失败原因 |
durationMs | 最近一次请求耗时 |
nextAt | 下次投递时间 |
重发事件
http
POST /v1/webhooks/events/{eventId}/resend把该事件在本租户各端点上已有的投递记录(含 DELIVERED、DEAD)重置为立即重新投递,重试计数清零。无请求体。
响应 200 OK
json
{ "eventId": "0192a4ca-1000-7000-8000-000000000002", "requeued": 1 }requeued 为重新排队的投递数。常见错误:404 NOT_FOUND(event not found)。
获取平台回调公钥
http
GET /v1/webhooks/public_key响应 200 OK
json
{ "keyId": "v1", "publicKey": "564c0078f4402815c003e474315a20e878f5ddc63e3e50cc30b86c38c16d10ea" }publicKey 是 32 字节 Ed25519 公钥的 hex 编码(上例仅为示例,请以接口返回为准)。
事件类型与签名说明
http
GET /v1/webhooks/event_types无需认证。返回事件类型列表与签名格式说明,便于程序自检。
响应 200 OK
json
{
"eventTypes": ["TRANSACTION_CREATED", "TRANSACTION_STATUS_UPDATED", "WALLET_CREATED", "DEPOSIT_REVERSED", "MANUAL_DEPOSIT_CREDITED", "VAULT_GAS_LOW", "WEBHOOK_TEST"],
"signature": {
"algorithm": "Ed25519",
"keyId": "v1",
"publicKey": "564c0078f4402815c003e474315a20e878f5ddc63e3e50cc30b86c38c16d10ea",
"headers": ["X-WaaS-Event-Id", "X-WaaS-Event-Type", "X-WaaS-Timestamp", "X-WaaS-Signature"],
"signedMessage": "<X-WaaS-Timestamp>.<raw request body>",
"signatureHeaderFormat": "keyId=v1,sig=<hex>"
},
"retrySchedule": ["1m", "5m", "30m", "2h", "12h"]
}