Webhook 用于实时通知商户系统交易状态变更(如充值入账、付款完成等)。当事件发生时,NUSDpay 向商户配置的 Endpoint URL 发送 HTTP POST 请求。
工作机制
| 项目 | 说明 |
|---|
| 触发条件 | 交易状态变更时自动触发(如充值确认、付款完成等) |
| 请求方式 | HTTP POST |
| 超时时间 | 2 秒 |
| 重试策略 | 未收到 200/201 响应时自动重试,最多 10 次 |
| 响应要求 | 以 200 或 201 状态码响应,表示事件已成功接收 |
收到 Webhook 后,请先验证 wallet_id 是否为您的项目,非本项目的事件应忽略。
配置 Webhook
准备 Endpoint URL
在您的服务器上部署一个 HTTP 接口,用于接收 Webhook 事件。
提供 URL 给我们
将 Endpoint URL 提供给技术支持进行配置。
实现处理逻辑
在接口中实现:解析请求 → 验证签名 → 处理业务逻辑 → 响应 200/201。
验证签名
为防止未经授权的访问,收到 Webhook 事件时需验证签名。
- 从请求头中获取时间戳和签名:
bizTimestamp := r.Header.Get("biz-timestamp")
signature := r.Header.Get("biz-resp-signature")
-
拼接消息:
请求体原始字符串 + "|" + 时间戳
-
对消息进行双重 SHA256 哈希
-
使用 Ed25519 公钥验证签名
完整验签代码
package webhook
import (
"crypto/sha256"
"encoding/hex"
"errors"
"golang.org/x/crypto/ed25519"
)
func VerifySignature(rawBody string, bizTimestamp string, signatureHex string, pubKeyHex string) (bool, error) {
if bizTimestamp == "" || signatureHex == "" {
return false, errors.New("missing signature or timestamp")
}
msg := rawBody + "|" + bizTimestamp
h1 := sha256.Sum256([]byte(msg))
h2 := sha256.Sum256(h1[:])
msgHash := h2[:]
sig, err := hex.DecodeString(signatureHex)
if err != nil {
return false, err
}
pubKeyBytes, err := hex.DecodeString(pubKeyHex)
if err != nil {
return false, err
}
if len(pubKeyBytes) != ed25519.PublicKeySize {
return false, errors.New("invalid pubkey length")
}
if len(sig) != ed25519.SignatureSize {
return false, errors.New("invalid signature length")
}
ok := ed25519.Verify(ed25519.PublicKey(pubKeyBytes), msgHash, sig)
return ok, nil
}
Webhook 事件
事件类型
| 事件 | 说明 |
|---|
wallets.transaction.created | 交易已创建 |
wallets.transaction.updated | 交易状态更新 |
wallets.transaction.succeeded | 交易成功完成 |
NUSDpay 不保证事件按生成顺序到达。您的 Endpoint 应基于事件内容而非到达顺序处理业务逻辑。
重试机制
- 超时(2 秒内未响应)或非 200/201 响应:系统自动重试
- 最多重试 10 次
- 超过重试上限后,事件状态变为「发送失败」
建议使用 request_id 做幂等处理,防止重试导致重复消费。同时建议定期调用查询接口做对账兜底。
完整实现示例
package main
import (
"encoding/json"
"io"
"log"
"net/http"
"time"
"crypto/sha256"
"encoding/hex"
"golang.org/x/crypto/ed25519"
)
const (
PORT = "8000"
PUB_KEY = "YOUR_PUB_KEY_HEX"
)
var walletID = "YOUR_WALLET_ID"
type WebhookPayload struct {
Data struct {
WalletID string `json:"wallet_id"`
} `json:"data"`
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Read body failed", http.StatusBadRequest)
return
}
defer r.Body.Close()
// 验证签名
signature := r.Header.Get("biz-resp-signature")
bizTimestamp := r.Header.Get("biz-timestamp")
message := string(rawBody) + "|" + bizTimestamp
h1 := sha256.Sum256([]byte(message))
h2 := sha256.Sum256(h1[:])
sig, _ := hex.DecodeString(signature)
pubKey, _ := hex.DecodeString(PUB_KEY)
if !ed25519.Verify(ed25519.PublicKey(pubKey), h2[:], sig) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// 解析并处理
var payload WebhookPayload
json.Unmarshal(rawBody, &payload)
if payload.Data.WalletID == walletID {
// 处理业务逻辑
log.Println("Received event for wallet:", walletID)
}
w.WriteHeader(http.StatusCreated)
}
func main() {
http.HandleFunc("/api/webhook", webhookHandler)
log.Printf("Server running on :%s\n", PORT)
log.Fatal((&http.Server{
Addr: ":" + PORT, Handler: nil,
ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second,
}).ListenAndServe())
}
const express = require("express");
const CryptoJS = require("crypto-js");
const nacl = require("tweetnacl");
const PUB_KEY = "YOUR_PUB_KEY_HEX";
const WALLET_ID = "YOUR_WALLET_ID";
const app = express();
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = buf.toString(); }
}));
app.post("/api/webhook", (req, res) => {
// 验证签名
const signature = req.headers['biz-resp-signature'];
const bizTimestamp = req.headers['biz-timestamp'];
const message = `${req.rawBody}|${bizTimestamp}`;
const hash = CryptoJS.SHA256(CryptoJS.SHA256(message)).toString(CryptoJS.enc.Hex);
try {
const sigBuf = Buffer.from(signature, "hex");
const msgBuf = Buffer.from(hash, "hex");
const vk = Buffer.from(PUB_KEY, "hex");
if (!nacl.sign.detached.verify(msgBuf, sigBuf, vk)) {
return res.status(401).send("Invalid signature");
}
} catch (e) {
return res.status(401).send("Verification failed");
}
// 处理业务逻辑
const data = JSON.parse(req.rawBody);
if (data.data.wallet_id === WALLET_ID) {
console.log("Received event for wallet:", WALLET_ID);
}
res.sendStatus(201);
});
app.listen(8000, () => console.log("Server running on :8000"));
下一步
充值回调
查看充值事件的 Payload 字段说明。
提现回调
查看提现事件的 Payload 字段说明。