Webhook 通知
概述
当支付状态发生变化时(如支付完成、支付失败、退款成功等),StablePay 会向商户配置的 Webhook URL 发送 HTTP POST 请求通知商户。商户需要正确接收并验证这些通知,以便更新订单状态。
基础信息
| 项目 | 值 |
|---|---|
| 请求方法 | POST |
| Content-Type | application/json |
| 字符编码 | UTF-8 |
| 超时时间 | 30 秒 |
请求头
| Header | 类型 | 必填 | 说明 |
|---|---|---|---|
Content-Type | string | 是 | 固定值 application/json |
X-StablePay-Signature | string | 是 | HMAC-SHA256 签名(十六进制编码) |
X-StablePay-Timestamp | string | 是 | Unix 时间戳(秒),签名生成时间 |
X-StablePay-Nonce | string | 是 | UUID v4 随机数,用于防重放攻击 |
X-StablePay-Event-Type | string | 是 | 事件类型(如 payment.completed) |
X-StablePay-Event-ID | string | 是 | 事件唯一标识,用于幂等性检查 |
User-Agent | string | 是 | 固定值 StablePay-Webhook/1.0 |
请求头示例
POST /webhook/stablepay HTTP/1.1
Host: your-server.com
Content-Type: application/json
X-StablePay-Signature: 2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b
X-StablePay-Timestamp: 1765786800
X-StablePay-Nonce: 550e8400-e29b-41d4-a716-446655440000
X-StablePay-Event-Type: payment.completed
X-StablePay-Event-ID: rec_abc123def456
User-Agent: StablePay-Webhook/1.0
签名验证
为确保 Webhook 请求的真实性和完整性,所有请求都包含 HMAC-SHA256 签名。商户必须验证签名后再处理请求。
签名算法
1. 获取请求头中的 timestamp、nonce 和原始请求体 (request_body)
2. 拼接签名字符串: sign_string = timestamp + "." + nonce + "." + request_body
3. 使用商户的 Secret Key 进行 HMAC-SHA256 计算
4. 将结果进行十六进制编码
5. 与请求头中的 X-StablePay-Signature 进行比对
签名验证步骤
- 获取签名参数:从请求头获取
X-StablePay-Signature、X-StablePay-Timestamp、X-StablePay-Nonce - 验证时间戳:检查 timestamp 与当前时间差是否在 5 分钟以内(防重放攻击)
- 获取原始请求体:必须使用原始字节流,不能先解析再序列化
- 计算期望签名:按上述算法计算签名
- 安全比对:使用时间安全的比较函数(如
hmac.compare_digest)
签名验证代码示例
JavaScript/Node.js:
const crypto = require('crypto');
function verifyWebhookSignature(secretKey, signature, timestamp, nonce, requestBody) {
// 1. 验证时间戳(5分钟有效期)/ Validate timestamp (5-minute validity)
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - parseInt(timestamp)) > 300) {
return false;
}
// 2. 构建签名字符串 / Build signature string
const signString = `${timestamp}.${nonce}.${requestBody}`;
// 3. 计算期望签名 / Calculate expected signature
const expectedSignature = crypto
.createHmac('sha256', secretKey)
.update(signString)
.digest('hex');
// 4. 时间安全比较 / Timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// Express.js 中间件示例 / Express.js middleware example
app.post('/webhook/stablepay', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-stablepay-signature'];
const timestamp = req.headers['x-stablepay-timestamp'];
const nonce = req.headers['x-stablepay-nonce'];
const requestBody = req.body.toString();
if (!verifyWebhookSignature(SECRET_KEY, signature, timestamp, nonce, requestBody)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 处理 webhook 事件 / Process webhook event
const event = JSON.parse(requestBody);
// ...
res.status(200).json({ received: true });
});
Python:
import hmac
import hashlib
import time
def verify_webhook_signature(secret_key, signature, timestamp, nonce, request_body):
"""验证 Webhook 签名 / Verify Webhook signature"""
# 1. 验证时间戳(5分钟有效期)/ Validate timestamp (5-minute validity)
current_time = int(time.time())
if abs(current_time - int(timestamp)) > 300:
return False
# 2. 构建签名字符串 / Build signature string
sign_string = f"{timestamp}.{nonce}.{request_body}"
# 3. 计算期望签名 / Calculate expected signature
expected_signature = hmac.new(
secret_key.encode('utf-8'),
sign_string.encode('utf-8'),
hashlib.sha256
).hexdigest()
# 4. 时间安全比较 / Timing-safe comparison
return hmac.compare_digest(expected_signature, signature)
# Flask 示例 / Flask example
from flask import Flask, request, jsonify
@app.route('/webhook/stablepay', methods=['POST'])
def webhook_handler():
signature = request.headers.get('X-StablePay-Signature')
timestamp = request.headers.get('X-StablePay-Timestamp')
nonce = request.headers.get('X-StablePay-Nonce')
request_body = request.get_data(as_text=True)
if not verify_webhook_signature(SECRET_KEY, signature, timestamp, nonce, request_body):
return jsonify({'error': 'Invalid signature'}), 401
# 处理 webhook 事件 / Process webhook event
event = request.get_json()
# ...
return jsonify({'received': True}), 200
Go:
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"io"
"math"
"net/http"
"strconv"
"time"
)
func verifyWebhookSignature(secretKey, signature, timestamp, nonce, requestBody string) bool {
// 1. 验证时间戳(5分钟有效期)/ Validate timestamp (5-minute validity)
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false
}
// 2. 构建签名字符串 / Build signature string
signString := fmt.Sprintf("%s.%s.%s", timestamp, nonce, requestBody)
// 3. 计算期望签名 / Calculate expected signature
mac := hmac.New(sha256.New, []byte(secretKey))
mac.Write([]byte(signString))
expectedSignature := hex.EncodeToString(mac.Sum(nil))
// 4. 时间安全比较 / Timing-safe comparison
return subtle.ConstantTimeCompare([]byte(signature), []byte(expectedSignature)) == 1
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
signature := r.Header.Get("X-StablePay-Signature")
timestamp := r.Header.Get("X-StablePay-Timestamp")
nonce := r.Header.Get("X-StablePay-Nonce")
body, _ := io.ReadAll(r.Body)
requestBody := string(body)
if !verifyWebhookSignature(secretKey, signature, timestamp, nonce, requestBody) {
http.Error(w, `{"error": "Invalid signature"}`, http.StatusUnauthorized)
return
}
// 处理 webhook 事件 / Process webhook event
// ...
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"received": true}`))
}
PHP:
<?php
function verifyWebhookSignature($secretKey, $signature, $timestamp, $nonce, $requestBody) {
// 1. 验证时间戳(5分钟有效期)/ Validate timestamp (5-minute validity)
if (abs(time() - intval($timestamp)) > 300) {
return false;
}
// 2. 构建签名字符串 / Build signature string
$signString = $timestamp . '.' . $nonce . '.' . $requestBody;
// 3. 计算期望签名 / Calculate expected signature
$expectedSignature = hash_hmac('sha256', $signString, $secretKey);
// 4. 时间安全比较 / Timing-safe comparison
return hash_equals($expectedSignature, $signature);
}
// 使用示例 / Usage example
$signature = $_SERVER['HTTP_X_STABLEPAY_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_STABLEPAY_TIMESTAMP'] ?? '';
$nonce = $_SERVER['HTTP_X_STABLEPAY_NONCE'] ?? '';
$requestBody = file_get_contents('php://input');
if (!verifyWebhookSignature($secretKey, $signature, $timestamp, $nonce, $requestBody)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
// 处理 webhook 事件 / Process webhook event
$event = json_decode($requestBody, true);
// ...
http_response_code(200);
echo json_encode(['received' => true]);
事件类型
支付事件
| 事件类型 | 说明 | 触发条件 |
|---|---|---|
payment.completed | 支付成功 | 用户完成支付,链上确认成功 |
payment.failed | 支付失败 | 支付过程中发生错误 |
payment.expired | 支付过期 | 支付会话超时未完成 |
payment.cancelled | 支付取消 | 商户主动取消支付会话 |
退款事件
| 事件类型 | 说明 | 触发条件 |
|---|---|---|
refund.succeeded | 退款成功 | 退款处理完成,资金已退回 |
refund.failed | 退款失败 | 退款处理失败 |
请求体格式
支付事件请求体
{
"id": "evt_1765786800547928039",
"type": "payment.completed",
"created_at": 1765786800,
"data": {
"object": {
"amount": "100.00",
"status": "completed",
"currency": "USDT",
"exchange_rate": "7.25000000",
"order_id": "ORDER-20250101-001",
"session_id": "sess_abc123def456",
"source": "api"
}
}
}
支付字段说明
| 字段路径 | 类型 | 说明 |
|---|---|---|
id | string | 事件 ID,格式 evt_{纳秒时间戳} |
type | string | 事件类型 |
created_at | int64 | 事件创建时间(Unix 时间戳,秒) |
data.object.amount | string | 支付金额(失败/过期时可能为空) |
data.object.status | string | 状态:completed / failed / expired / canceled |
data.object.currency | string | 支付货币:USDT / USDC(失败/过期时可能为空) |
data.object.exchange_rate | string | 汇率值 |
data.object.order_id | string | 商户订单 ID |
data.object.session_id | string | 支付会话 ID |
data.object.source | string | 来源系统:api |
退款事件请求体
{
"id": "evt_1765786800547928040",
"type": "refund.succeeded",
"created_at": 1765786800,
"data": {
"object": {
"session_id": "sess_abc123def456",
"order_id": "ORDER-20250101-001",
"refund_id": "ref_xyz789",
"refund_amount": "50.00",
"refund_currency": "USDT",
"status": "completed",
"source": "api"
}
}
}
退款字段说明
| 字段路径 | 类型 | 说明 |
|---|---|---|
id | string | 事件 ID |
type | string | 事件类型 |
created_at | int64 | 事件创建时间 |
data.object.session_id | string | 原支付会话 ID |
data.object.order_id | string | 商户订单 ID |
data.object.refund_id | string | 退款 ID |
data.object.refund_amount | string | 退款金额 |
data.object.refund_currency | string | 退款货币 |
data.object.status | string | 退款状态:completed / failed |
data.object.source | string | 来源系统 |
响应要求
重试策略
当 Webhook 发送失败(网络错误或非 2xx 响应)时,StablePay 会自动重试。
| 配置项 | 值 |
|---|---|
| 最大重试次数 | 5 次 |
| 重试间隔 | 指数退避(1s, 2s, 4s, 8s, 16s) |
| 超时时间 | 30 秒 |
| 可重试状态码 | 429, 5xx |
幂等性处理
由于网络问题或重试机制,商户可能收到重复的 Webhook 通知。商户必须实现幂等性处理。
建议实现方式
- 使用 Event ID:将
X-StablePay-Event-ID或请求体中的id字段作为唯一键 - 记录已处理事件:在数据库中记录已处理的事件 ID
- 检查重复:处理前检查事件是否已处理过
- 定期清理:建议保留 7-30 天的处理记录后清理
幂等性示例(SQL)
-- 创建事件记录表 / Create event record table
CREATE TABLE webhook_events (
event_id VARCHAR(64) PRIMARY KEY,
event_type VARCHAR(50) NOT NULL,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_processed_at (processed_at)
);
-- 处理事件前检查 / Check before processing event
INSERT INTO webhook_events (event_id, event_type)
VALUES ('evt_xxx', 'payment.completed')
ON DUPLICATE KEY UPDATE event_id = event_id;
-- 如果 affected_rows = 1,表示首次处理
-- If affected_rows = 1, it's first-time processing
-- 如果 affected_rows = 0,表示已处理过,跳过
-- If affected_rows = 0, already processed, skip
安全建议
- 始终验证签名:不要跳过签名验证步骤
- 使用 HTTPS:Webhook URL 必须使用 HTTPS
- 验证时间戳:拒绝时间戳偏差过大的请求(建议 5 分钟)
- 保护 Secret Key:妥善保管密钥,不要泄露到客户端或日志
- 使用安全比较:使用时间安全的比较函数防止时序攻击
- 快速响应:在 30 秒内完成处理并返回响应
- 异步处理:对于耗时操作,先返回成功响应,后台异步处理
完整请求示例
POST /webhook/stablepay HTTP/1.1
Host: your-server.com
Content-Type: application/json
X-StablePay-Signature: 2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b
X-StablePay-Timestamp: 1765786800
X-StablePay-Nonce: 550e8400-e29b-41d4-a716-446655440000
X-StablePay-Event-Type: payment.completed
X-StablePay-Event-ID: rec_abc123def456
User-Agent: StablePay-Webhook/1.0
Content-Length: 285
{
"id": "evt_1765786800547928039",
"type": "payment.completed",
"created_at": 1765786800,
"data": {
"object": {
"amount": "100.00",
"status": "completed",
"currency": "USDT",
"exchange_rate": "7.25000000",
"order_id": "ORDER-20250101-001",
"session_id": "sess_abc123def456",
"source": "api"
}
}
}