Webhook 用于实时通知商户系统交易状态变更(如充值入账、付款完成等)。当事件发生时,NUSDpay 向商户配置的 Endpoint URL 发送 HTTP POST 请求。

工作机制

项目说明
触发条件交易状态变更时自动触发(如充值确认、付款完成等)
请求方式HTTP POST
超时时间2 秒
重试策略未收到 200/201 响应时自动重试,最多 10 次
响应要求200201 状态码响应,表示事件已成功接收
收到 Webhook 后,请先验证 wallet_id 是否为您的项目,非本项目的事件应忽略。

配置 Webhook

1

准备 Endpoint URL

在您的服务器上部署一个 HTTP 接口,用于接收 Webhook 事件。
2

提供 URL 给我们

将 Endpoint URL 提供给技术支持进行配置。
3

实现处理逻辑

在接口中实现:解析请求 → 验证签名 → 处理业务逻辑 → 响应 200/201。

验证签名

为防止未经授权的访问,收到 Webhook 事件时需验证签名。

步骤

  1. 从请求头中获取时间戳和签名:
bizTimestamp := r.Header.Get("biz-timestamp")
signature := r.Header.Get("biz-resp-signature")
  1. 拼接消息:请求体原始字符串 + "|" + 时间戳
  2. 对消息进行双重 SHA256 哈希
  3. 使用 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
}
PUB_KEY 请联系技术支持获取。

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 字段说明。