Skip to content

回调(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-SignaturekeyId=v1,sig=<hex>:对 "<X-WaaS-Timestamp>.<原始请求体>" 的 Ed25519 签名(64 字节,hex 编码)

完整的交易事件示例见 充值 · 回调示例。

签名验证 ​

  1. 获取平台回调公钥:GET /v1/webhooks/public_key,返回 {"keyId":"v1","publicKey":"<64 位 hex>"}。公钥可以缓存;keyId 用于将来轮换。
  2. 取原始请求体字节(不要先解析 JSON 再序列化),拼接 message = X-WaaS-Timestamp + "." + rawBody。
  3. 从 X-WaaS-Signature 中解析 keyId 与 sig,确认 keyId = v1,用公钥对 message 验证 sig。
  4. 建议同时检查 X-WaaS-Timestamp 与当前时间相差不超过 5 分钟,防止重放。
  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", 200

Go ​

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 去重。

对账建议 ​

回调用于实时性,不应作为唯一的数据来源:

  1. 处理回调时以交易 id 为唯一键落库,并以 data.status 更新状态;已到终态的交易忽略非终态事件(终态之后只有充值 COMPLETED → REVERSED 一种变化,见 交易状态)。
  2. 每隔几分钟用 GET /v1/transactions?createdFrom=<上次水位-重叠窗口>&limit=200 翻页拉取,补齐漏收的事件;对仍处于非终态的交易用 GET /v1/transactions/{id} 刷新。
  3. 每日按 kind、assetId、status = COMPLETED 汇总,与你方账务核对;必要时与 GET /v1/users/{customerRefId}/balances 抽样比对。
  4. 定期查看 GET /v1/webhooks/deliveries?status=DEAD,排查并重发失败事件。

管理接口 ​

以下接口都需要 签名认证(GET /v1/webhooks/event_types 除外)。也可以在租户后台的“回调”页面操作。

回调端点对象 ​

字段类型说明
idstring (UUID)端点 ID
urlstring回调地址
eventsstring[]订阅的事件类型;空数组 = 全部事件
descriptionstring | null备注
statusstringACTIVE / DISABLED
createdAtstring创建时间

注册回调端点 ​

http
POST /v1/webhooks

请求体

字段类型必填说明
urlstring是回调地址,≤ 2048 字符,不能含空白或 user:pass@ 凭据;生产环境必须 https://
eventsstring[]否订阅的事件类型(见 事件类型),省略或空数组 = 全部
descriptionstring否备注
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"
}

常见错误

HTTPcode场景
422BAD_REQUESTinvalid url;webhook url must use https;webhook url must start with https://;webhook url must not contain credentials;unknown event type …
422LIMIT_EXCEEDED已有 10 个 ACTIVE 端点(at most 10 active webhook endpoints)

本接口不幂等,重复调用会创建多个端点。

回调端点列表 ​

http
GET /v1/webhooks

响应 200 OK:回调端点对象 数组(含已停用的),按创建时间倒序。

修改回调端点 ​

http
PATCH /v1/webhooks/{id}

请求体(字段都可选,未提供的保持不变)

字段类型说明
urlstring新地址,校验规则同注册
eventsstring[]新的订阅列表(空数组 = 全部事件)
descriptionstring备注
activebooleantrue 启用,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>

查询参数

参数类型说明
endpointIdUUID只看某个端点
statusstringPENDING(待投递/等待重试)、DELIVERED、DEAD
limitinteger1–200,默认 50
beforeinteger翻页:上一页最后一条的 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"]
}