Webhook 回调
接收和处理支付事件的实时通知
7 分钟阅读
更新于 2025/8/15
WebhookAPI通知
Webhook 是 Payve 向您的服务器推送支付事件实时通知的机制。当支付状态发生变化时,我们会向您预先配置的回调 URL 发送 HTTP POST 请求。
概述
Webhook 通知采用 至少一次投递(At-Least-Once) 语义,确保重要的支付事件不会丢失。您的服务器需要正确处理重复通知,实现幂等性。
关键特性
- ✅ 实时通知 - 支付确认后立即推送
- ✅ 可靠投递 - 自动重试机制,最长重试 72 小时
- ✅ 安全验证 - HMAC-SHA256 签名验证
- ✅ 幂等去重 - 唯一事件 ID 防止重复处理
- ✅ 智能重试 - 指数退避算法,避免系统过载
快速开始
1. 配置 Webhook 端点
在商户控制台设置您的 Webhook 接收地址:
https://your-domain.com/api/webhooks/payve2. 获取 Webhook 密钥
系统会为您生成一个唯一的 Webhook 密钥,用于验证请求签名。请妥善保管此密钥。
3. 实现接收端点
// Express.js 示例
app.post('/api/webhooks/payve', async (req, res) => {
// 验证签名
const signature = req.headers['x-pgw-signature'];
const timestamp = req.headers['x-pgw-timestamp'];
const eventId = req.headers['x-pgw-event-id'];
// 验证请求合法性
if (!verifyWebhookSignature(req.rawBody, signature, timestamp)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 检查时间戳,防止重放攻击(5分钟内)
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - parseInt(timestamp)) > 300) {
return res.status(401).json({ error: 'Request too old' });
}
// 幂等性检查 - 防止重复处理
if (await isEventProcessed(eventId)) {
return res.status(200).json({ status: 'ok' }); // 已处理过,直接返回成功
}
// 立即返回 200,异步处理业务逻辑
res.status(200).json({ status: 'ok' });
// 异步处理支付确认逻辑
processPaymentAsync(req.body);
});JAVASCRIPT
代码已折叠
共 29 行
请求格式
HTTP 请求头
| 请求头 | 说明 | 示例 |
|---|---|---|
X-PGW-Event-Id | 全局唯一事件 ID | evt_1a2b3c4d5e6f |
X-PGW-Delivery-Id | 本次投递 ID | dlv_9z8y7x6w5v4u |
X-PGW-Attempt | 重试次数(从1开始) | 1 |
X-PGW-Timestamp | Unix 时间戳(秒) | 1708123456 |
X-PGW-Signature | HMAC-SHA256 签名 | t=1708123456, v1=abc123... |
X-PGW-Signature-Alg | 签名算法 | HMAC-SHA256 |
User-Agent | 客户端标识 | Payve-Webhook/1.0 |
Content-Type | 内容类型 | application/json |
请求体格式
{
"type": "payment.succeeded",
"event_id": "evt_1a2b3c4d5e6f",
"occurred_at": "2024-02-17T10:30:45Z",
"data": {
"payment_id": "pay_abc123def456",
"merchant_order_id": "ORDER_2024021701",
"amount": "100.00",
"currency": "USDC",
"network": "SOLANA",
"tx_hash": "2bFVEp4xDRU9u9asXQ5SQ94W1sUbk4MXF8Pb...",
"address": "FsnXdmyWYFFTb4G5sDxn2ND5T5ZwP7Eje6ajpfRT26on",
"status": "COMPLETED",
"metadata": {
"user_id": "12345",
"product_name": "Premium Plan"
},
"confirmed_at": "2024-02-17T10:30:45Z"
}
}JSON
代码已折叠
共 20 行
事件类型
| 事件类型 | 说明 | 触发时机 |
|---|---|---|
payment.succeeded | 支付成功 | 收到足额支付并确认 |
payment.failed | 支付失败 | 支付失败或超时 |
payment.expired | 支付过期 | 超过支付时限未收到支付 |
payment.refunded | 退款成功 | 退款处理完成 |
签名验证
验证算法
签名使用 HMAC-SHA256 算法,计算方式:
function verifyWebhookSignature(rawBody, signatureHeader, timestamp) {
// 解析签名头
const parts = signatureHeader.split(', ');
const timestampPart = parts.find(p => p.startsWith('t='));
const signaturePart = parts.find(p => p.startsWith('v1='));
if (!timestampPart || !signaturePart) {
return false;
}
const receivedSignature = signaturePart.split('=')[1];
// 计算期望签名
const crypto = require('crypto');
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
// 使用恒定时间比较防止时序攻击
return crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(receivedSignature)
);
}JAVASCRIPT
代码已折叠
共 25 行
重要提示
- 必须使用原始请求体(raw body)进行签名验证,不要使用解析后的 JSON
- 验证时间戳防止重放攻击,建议设置 5 分钟的时间窗口
- 使用恒定时间比较防止时序攻击
响应要求
成功响应
HTTP 状态码: 200-299
{
"status": "ok"
}⚠️ 重要: 必须在 5 秒内 返回 2xx 状态码,否则视为失败并触发重试。
最佳实践
- 快速响应 - 收到请求后立即返回 2xx,然后异步处理业务逻辑
- 幂等处理 - 使用
event_id去重,避免重复处理同一事件 - 错误处理 - 临时错误返回 5xx 触发重试,永久错误返回 4xx
重试机制
重试策略
当 Webhook 调用失败时(非 2xx 响应、超时、网络错误),系统会自动重试:
| 重试次数 | 延迟时间 | 累计时间 |
|---|---|---|
| 1 | 1 分钟 | 1 分钟 |
| 2 | 5 分钟 | 6 分钟 |
| 3 | 15 分钟 | 21 分钟 |
| 4 | 1 小时 | 1.3 小时 |
| 5 | 3 小时 | 4.3 小时 |
| 6 | 6 小时 | 10.3 小时 |
| 7 | 12 小时 | 22.3 小时 |
| 8 | 24 小时 | 46.3 小时 |
| 9 | 24 小时 | 70.3 小时 |
📝 说明:
- 最多重试 10 次或 72 小时(以先到达者为准)
- 每次重试间隔会加入 ±20% 的随机抖动,避免雪崩效应
- 如果收到
429 Too Many Requests响应,将遵守Retry-After头部
特殊状态码处理
| HTTP 状态码 | 处理方式 | 说明 |
|---|---|---|
| 2xx | 成功 | 停止重试,标记为成功 |
| 408, 425, 429, 5xx | 重试 | 按照重试策略继续尝试 |
| 410 Gone | 停止 | 端点已失效,停止重试并禁用 Webhook |
| 其他 4xx | 有限重试 | 重试 3 次后停止 |
安全建议
1. 验证所有请求
// 始终验证签名
if (!verifySignature(req)) {
return res.status(401).send('Unauthorized');
}2. 使用 HTTPS
确保您的 Webhook 端点使用 HTTPS 协议,避免中间人攻击。
3. IP 白名单(可选)
如需额外安全保障,可以将 Payve 的 IP 地址加入白名单:
待更新 IP 列表
4. 密钥轮换
定期更换 Webhook 密钥,建议每 90 天轮换一次。
实现示例
Node.js (Express)
const express = require('express');
const crypto = require('crypto');
const app = express();
// 保存原始请求体
app.use('/api/webhooks/payve', express.raw({ type: 'application/json' }));
app.post('/api/webhooks/payve', async (req, res) => {
const signature = req.headers['x-pgw-signature'];
const timestamp = req.headers['x-pgw-timestamp'];
const eventId = req.headers['x-pgw-event-id'];
// 1. 验证签名
const isValid = verifySignature(
req.body.toString(),
signature,
timestamp,
process.env.WEBHOOK_SECRET
);
if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 2. 解析事件
const event = JSON.parse(req.body.toString());
// 3. 幂等性检查
const processed = await checkIfProcessed(eventId);
if (processed) {
return res.status(200).json({ status: 'ok' });
}
// 4. 立即响应
res.status(200).json({ status: 'ok' });
// 5. 异步处理
processEventAsync(event);
});
function verifySignature(rawBody, signature, timestamp, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const receivedSignature = signature.split('v1=')[1];
return crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(receivedSignature)
);
}JAVASCRIPT
代码已折叠
共 53 行
Python (Flask)
from flask import Flask, request, jsonify
import hmac
import hashlib
import time
app = Flask(__name__)
@app.route('/api/webhooks/payve', methods=['POST'])
def handle_webhook():
# 获取请求头
signature = request.headers.get('X-PGW-Signature')
timestamp = request.headers.get('X-PGW-Timestamp')
event_id = request.headers.get('X-PGW-Event-Id')
# 1. 验证签名
raw_body = request.get_data(as_text=True)
if not verify_signature(raw_body, signature, timestamp):
return jsonify({'error': 'Invalid signature'}), 401
# 2. 验证时间戳(5分钟内)
current_time = int(time.time())
if abs(current_time - int(timestamp)) > 300:
return jsonify({'error': 'Request too old'}), 401
# 3. 幂等性检查
if is_event_processed(event_id):
return jsonify({'status': 'ok'}), 200
# 4. 立即返回成功
response = jsonify({'status': 'ok'})
# 5. 异步处理(使用任务队列)
process_payment_async.delay(request.json)
return response, 200
def verify_signature(raw_body, signature_header, timestamp):
secret = os.environ.get('WEBHOOK_SECRET')
# 解析签名
parts = signature_header.split(', ')
received_signature = None
for part in parts:
if part.startswith('v1='):
received_signature = part.split('=')[1]
break
if not received_signature:
return False
# 计算期望签名
message = f"{timestamp}.{raw_body}"
expected_signature = hmac.new(
secret.encode(),
message.encode(),
hashlib.sha256
).hexdigest()
# 恒定时间比较
return hmac.compare_digest(expected_signature, received_signature)PYTHON
代码已折叠
共 60 行
PHP
<?php
function handleWebhook() {
// 获取请求头
$headers = getallheaders();
$signature = $headers['X-PGW-Signature'] ?? '';
$timestamp = $headers['X-PGW-Timestamp'] ?? '';
$eventId = $headers['X-PGW-Event-Id'] ?? '';
// 获取原始请求体
$rawBody = file_get_contents('php://input');
// 1. 验证签名
if (!verifySignature($rawBody, $signature, $timestamp)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
// 2. 验证时间戳
$currentTime = time();
if (abs($currentTime - intval($timestamp)) > 300) {
http_response_code(401);
echo json_encode(['error' => 'Request too old']);
exit;
}
// 3. 幂等性检查
if (isEventProcessed($eventId)) {
http_response_code(200);
echo json_encode(['status' => 'ok']);
exit;
}
// 4. 立即返回成功
http_response_code(200);
echo json_encode(['status' => 'ok']);
// 5. 异步处理(使用后台任务)
fastcgi_finish_request();
processPaymentAsync(json_decode($rawBody, true));
}
function verifySignature($rawBody, $signatureHeader, $timestamp) {
$secret = $_ENV['WEBHOOK_SECRET'];
// 解析签名
$parts = explode(', ', $signatureHeader);
$receivedSignature = null;
foreach ($parts as $part) {
if (strpos($part, 'v1=') === 0) {
$receivedSignature = substr($part, 3);
break;
}
}
if (!$receivedSignature) {
return false;
}
// 计算期望签名
$message = $timestamp . '.' . $rawBody;
$expectedSignature = hash_hmac('sha256', $message, $secret);
// 恒定时间比较
return hash_equals($expectedSignature, $receivedSignature);
}
?>PHP
代码已折叠
共 67 行
测试工具
使用 ngrok 进行本地测试
- 安装 ngrok:
brew install ngrok # macOS
# 或访问 https://ngrok.com/download- 暴露本地端口:
ngrok http 3000- 使用 ngrok 提供的 HTTPS URL 作为 Webhook 端点
模拟 Webhook 请求
curl -X POST https://your-domain.com/api/webhooks/payve \
-H "Content-Type: application/json" \
-H "X-PGW-Event-Id: evt_test123" \
-H "X-PGW-Delivery-Id: dlv_test456" \
-H "X-PGW-Attempt: 1" \
-H "X-PGW-Timestamp: $(date +%s)" \
-H "X-PGW-Signature: t=$(date +%s), v1=test_signature" \
-H "X-PGW-Signature-Alg: HMAC-SHA256" \
-d '{
"type": "payment.succeeded",
"event_id": "evt_test123",
"occurred_at": "2024-02-17T10:30:45Z",
"data": {
"payment_id": "pay_test789",
"amount": "100.00",
"currency": "USDC",
"network": "SOLANA"
}
}'BASH
代码已折叠
共 19 行
故障排查
常见问题
1. 签名验证失败
可能原因:
- 使用了解析后的 JSON 而非原始请求体
- Webhook 密钥不正确
- 请求体在传输过程中被修改
解决方案:
- 确保使用原始请求体(raw body)进行签名验证
- 检查 Webhook 密钥是否正确
- 记录并对比收到的请求体和签名
2. 重复接收通知
可能原因:
- 未正确实现幂等性
- 处理时间过长导致超时重试
解决方案:
- 使用
event_id实现幂等性检查 - 确保在 5 秒内返回响应
- 异步处理耗时操作
3. 收不到 Webhook 通知
可能原因:
- Webhook URL 配置错误
- 防火墙或安全组阻止了请求
- SSL 证书问题
解决方案:
- 检查 Webhook URL 是否正确且可访问
- 确保服务器允许来自 Payve 的请求
- 使用有效的 SSL 证书
日志记录建议
建议记录以下信息用于故障排查:
console.log({
event_id: req.headers['x-pgw-event-id'],
delivery_id: req.headers['x-pgw-delivery-id'],
attempt: req.headers['x-pgw-attempt'],
timestamp: req.headers['x-pgw-timestamp'],
signature_valid: isSignatureValid,
response_time: responseTime,
processing_result: result
});监控和告警
建议监控指标
- Webhook 接收成功率 - 监控 2xx 响应比例
- 响应时间 - 确保 P95 < 5 秒
- 重复事件数量 - 监控幂等性过滤的事件数
- 签名验证失败 - 可能的安全问题
告警阈值建议
- 连续 5 次 Webhook 失败
- 响应时间超过 4 秒
- 签名验证失败率 > 1%
- 24 小时内无 Webhook 接收(业务活跃时)
API 参考
查询 Webhook 投递状态
GET /api/v1/webhooks/deliveries/{event_id}
Authorization: Bearer YOUR_API_KEY响应示例:
{
"event_id": "evt_1a2b3c4d5e6f",
"status": "SUCCESS",
"attempts": 1,
"last_attempt_at": "2024-02-17T10:30:50Z",
"next_retry_at": null,
"response_code": 200,
"response_time_ms": 150
}重新发送 Webhook
POST /api/v1/webhooks/resend
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"event_id": "evt_1a2b3c4d5e6f"
}更新 Webhook 配置
PUT /api/v1/merchant/webhook
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"url": "https://new-domain.com/webhooks",
"enabled": true
}