Webhook
管理 webhook
注册一个公开的 https 地址,当订单或补充发生变化时,每次调用会向它发送一个签名事件。端点属于账户,每个端点都有自己的密钥。
https://notpanel.com/api/v3action=webhook.add- 需要 API key
- 请求过于频繁
- 请求体:
application/x-www-form-urlencoded
参数
始终发送
keystring必需- 你的 API key。
actionstring必需webhook.add必须是字面字符串 “webhook.add”。
操作参数
urlstring (URL)必需- 接收事件的公开 https 地址。
eventsstring (CSV)可选- 要订阅的事件类型,以逗号分隔,共 8 种。省略则接收全部。order.processing, order.in_progress, order.completed, order.partial, order.canceled, order.cancel_refused, refill.completed, refill.rejected
curl -X POST https://notpanel.com/api/v3 \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "key=YOUR_API_KEY&action=webhook.add&url=https://your-server.example.com/notpanel-webhook&events=order.completed,order.partial,order.canceled"const res = await fetch("https://notpanel.com/api/v3", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
key: "YOUR_API_KEY",
action: "webhook.add",
url: "https://your-server.example.com/notpanel-webhook",
events: "order.completed,order.partial,order.canceled",
}),
});
const data = await res.json();
console.log(data);import requests
res = requests.post(
"https://notpanel.com/api/v3",
data={
"key": "YOUR_API_KEY",
"action": "webhook.add",
"url": "https://your-server.example.com/notpanel-webhook",
"events": "order.completed,order.partial,order.canceled",
},
)
print(res.json())<?php
$body = http_build_query([
'key' => 'YOUR_API_KEY',
'action' => 'webhook.add',
'url' => 'https://your-server.example.com/notpanel-webhook',
'events' => 'order.completed,order.partial,order.canceled',
]);
$response = file_get_contents('https://notpanel.com/api/v3', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
'content' => $body,
'ignore_errors' => true,
],
]));
print_r(json_decode($response, true));
将 YOUR_API_KEY 替换为 API 页面中的密钥。
{
"id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
"url": "https://your-server.example.com/notpanel-webhook",
"events": ["order.completed", "order.partial", "order.canceled"],
"active": true,
"paused_at": null,
"created_at": "2026-10-03T09:00:00Z",
"status": "active",
"failureCount": 0,
"lastDeliveredAt": null,
"createdAt": "2026-10-03T09:00:00.000Z",
"secret": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"webhook_id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301"
}错误
- 400
{"error":"Invalid webhook URL","error_code":"INVALID_WEBHOOK_URL","retryable":false}该地址不是公开的 https 地址,或指向私有地址。
- 400
{"error":"Invalid events: not_allowed","error_code":"INVALID_PARAMETER","retryable":false,"details":{"param":"events","rule":"not_allowed"}}某个事件名称不在列表中。请检查拼写:名称形如
order.completed。 - 409
{"error":"Maximum 5 webhook endpoints per account","error_code":"WEBHOOK_LIMIT","retryable":false,"details":{"limit":5}}该账户已达到最多 5 个端点。请先移除一个。
事件
可订阅其中任意事件。每个事件在变更保存时作为单独的调用发送,被回滚的变更不会发送任何内容。ping 仅由 webhook.test 发送。
事件类型
order.processing- 订单已发送,正在等待交付开始。
order.in_progress- 交付已开始。
order.completed- 订单已完全交付。
order.partial- 订单已部分交付,其余部分已退款。
order.canceled- 订单已取消并退款。取代之前的
order.refunded。 order.cancel_refused- 取消请求被拒绝。订单继续进行。
refill.completed- 已完成一次补充。取代之前的
order.refill_completed。 refill.rejected- 一次补充被拒绝。数据中包含原因。
ping- 测试事件,仅由
webhook.test发送。
列出端点
action=webhook.list 返回账户的每个端点,包括其事件、是否启用,以及(如被暂停)暂停的时间。密钥绝不会返回。响应中也包含旧的键 status、failureCount、lastDeliveredAt 和 createdAt。
https://notpanel.com/api/v3action=webhook.listkey=YOUR_API_KEY&action=webhook.list[
{
"id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
"url": "https://your-server.example.com/notpanel-webhook",
"events": ["order.completed", "order.partial", "order.canceled"],
"active": true,
"paused_at": null,
"created_at": "2026-10-03T09:00:00Z",
"status": "active",
"failureCount": 0,
"lastDeliveredAt": null,
"createdAt": "2026-10-03T09:00:00.000Z"
}
]发送测试事件
action=webhook.test 向端点排入一个 ping。用它来检查你的接收端和签名代码。响应中包含投递 ID。
https://notpanel.com/api/v3action=webhook.testkey=YOUR_API_KEY&action=webhook.test&id=3f2504e0-4f89-41d3-9a0c-0305e82c3301{
"queued": true,
"delivery_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}移除端点
action=webhook.remove 会删除端点及其密钥。已在途的投递仍可能送达;不会再为它排入新的投递。
https://notpanel.com/api/v3action=webhook.removekey=YOUR_API_KEY&action=webhook.remove&id=3f2504e0-4f89-41d3-9a0c-0305e82c3301{
"removed": true,
"id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
"success": true
}投递内容
每次投递是一个 POST 中的一个事件,带有 Content-Type: application/json。正文是一个信封,data 以与 status 和 refill_status 相同的格式包含订单或补充。这是模式版本 1。
信封
idstring- 事件的唯一 ID。用它来忽略重复事件。
typestring- 事件类型。
schema_versioninteger- 此格式的版本。目前是 1。
created_atstring- 事件创建的时间。
sequenceinteger- 随账户的每个事件递增,因此你可以对事件排序。
dataobjectorder或refill,格式与对应的读取操作相同。ping 时为空。
请求头
X-Webhook-Signature- 对时间戳、一个点和完整正文计算的 HMAC-SHA256,写作
sha256=后接十六进制数字。 X-Webhook-Timestamp- 本次尝试的 Unix 时间(秒)。重试会获得新的时间戳和签名。
X-Webhook-Event- 事件类型,与正文中的
type相同。 X-Webhook-Delivery-Id- 本次投递的 ID,其每次重试都相同。
{
"id": "e1f0c9a2-3a8d-4a0e-9d8f-6c1b2a3d4e51",
"type": "order.completed",
"schema_version": 1,
"created_at": "2026-10-03T12:30:00Z",
"sequence": 1042,
"data": {
"order": {
"charge": "1.20",
"start_count": "3572",
"status": "Completed",
"remains": "0",
"currency": "USD",
"order": 1234567,
"status_key": "completed",
"refund": "0.00",
"reason_code": null,
"reason": null,
"can_cancel": false,
"cancel_available": false,
"cancel_state": null,
"can_refill": false,
"refill_available_at": "2026-10-03T18:35:00Z",
"updated_at": "2026-10-03T12:35:00Z"
}
}
}{
"id": "e1f0c9a2-3a8d-4a0e-9d8f-6c1b2a3d4e52",
"type": "order.partial",
"schema_version": 1,
"created_at": "2026-10-03T12:30:00Z",
"sequence": 1043,
"data": {
"order": {
"charge": "0.87",
"start_count": "3572",
"status": "Partial",
"remains": "157",
"currency": "USD",
"order": 1234568,
"status_key": "partial",
"refund": "0.13",
"reason_code": "partially_delivered",
"reason": "Part of the order was delivered. The rest was refunded.",
"can_cancel": false,
"cancel_available": false,
"cancel_state": null,
"can_refill": true,
"refill_available_at": null,
"updated_at": "2026-10-03T12:00:00Z"
}
}
}{
"id": "e1f0c9a2-3a8d-4a0e-9d8f-6c1b2a3d4e53",
"type": "order.canceled",
"schema_version": 1,
"created_at": "2026-10-03T12:30:00Z",
"sequence": 1044,
"data": {
"order": {
"charge": "0.00",
"start_count": "0",
"status": "Canceled",
"remains": "1000",
"currency": "USD",
"order": 1234569,
"status_key": "cancelled",
"refund": "1.20",
"reason_code": "customer_canceled",
"reason": "You canceled this order. The charge was refunded.",
"can_cancel": false,
"cancel_available": false,
"cancel_state": null,
"can_refill": false,
"refill_available_at": null,
"updated_at": "2026-10-03T10:00:09Z"
}
}
}{
"id": "e1f0c9a2-3a8d-4a0e-9d8f-6c1b2a3d4e56",
"type": "refill.rejected",
"schema_version": 1,
"created_at": "2026-10-03T12:30:00Z",
"sequence": 1047,
"data": {
"refill": {
"refill": 5502,
"status": "Rejected",
"status_key": "rejected",
"order": 1234568,
"reason_code": "refill_too_early",
"reason": "This order cannot be refilled yet. Try again later.",
"created_at": "2026-10-03T12:40:00Z",
"updated_at": "2026-10-03T12:41:00Z"
}
}
}验证签名
读取 X-Webhook-Timestamp 和正文的原始字节。用端点密钥对 <timestamp>.<body bytes> 计算 HMAC-SHA256,写作 sha256=<hex>,并以恒定时间与 X-Webhook-Signature 比较。重试的事件 ID 相同,但时间戳和签名是新的。
读取原始正文和请求头
计算并比较签名
跳过你已经处理过的事件
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* @param {{rawBody: unknown, signatureHeader: unknown, timestampHeader: unknown,
* secret: unknown, nowSeconds?: number, toleranceSeconds?: number}} input
*/
function verifyNotPanelBody({
rawBody,
signatureHeader,
timestampHeader,
secret,
nowSeconds = Math.floor(Date.now() / 1000),
toleranceSeconds = 300, // Example receiver policy, not a NotPanel rule.
}) {
if (!Buffer.isBuffer(rawBody)) return false;
if (typeof secret !== "string" || secret.length === 0) return false;
if (typeof signatureHeader !== "string") return false;
if (typeof timestampHeader !== "string") return false;
if (!/^(0|[1-9]\d{0,15})$/.test(timestampHeader)) return false;
if (!Number.isSafeInteger(nowSeconds)) return false;
if (!Number.isSafeInteger(toleranceSeconds) || toleranceSeconds < 0) {
return false;
}
const timestamp = Number(timestampHeader);
if (!Number.isSafeInteger(timestamp)) return false;
if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) return false;
const match = /^sha256=([0-9a-f]{64})$/i.exec(signatureHeader);
if (!match?.[1]) return false;
const expected = createHmac("sha256", secret)
.update(timestampHeader, "utf8")
.update(".", "utf8")
.update(rawBody)
.digest();
const received = Buffer.from(match[1], "hex");
return timingSafeEqual(expected, received);
}一个完整示例
用它来测试你的代码。签名涵盖时间戳、一个点和完全如所示的正文,不含多余的空格或换行。
secret = 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
timestamp = 1790000000
body = {"id":"e1f0c9a2-3a8d-4a0e-9d8f-6c1b2a3d4e51","type":"order.completed","schema_version":1,"created_at":"2026-10-03T12:30:00Z","sequence":1042,"data":{"order":{"charge":"1.20","start_count":"3572","status":"Completed","remains":"0","currency":"USD","order":1234567,"status_key":"completed","refund":"0.00","reason_code":null,"reason":null,"can_cancel":false,"cancel_available":false,"cancel_state":null,"can_refill":false,"refill_available_at":"2026-10-03T18:35:00Z","updated_at":"2026-10-03T12:35:00Z"}}}X-Webhook-Signature: sha256=6e8e90578f566d2e14b5c3914eba7e378c4107a5a4d4ddd52c747791f2264fff投递与重试
至少一次
在 10 秒内收到任何 2xx 响应即视为已送达。不会跟随重定向,且只读取你响应的前 4096 字节。同一事件可能到达多次,因此请忽略你已经处理过的事件 ID。
一天内八次尝试
共 8 次尝试:第一次立即进行,之后在事件发生后的 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h 进行。每次尝试都保持相同的事件 ID 和投递 ID。
端点会自行暂停
连续失败 50 次,或 3 天没有成功,端点会被暂停,并通知账户所有者。请修复你的接收端并重新添加端点。
不保证顺序
事件可能乱序到达。请使用
sequence排序,每个事件 ID 只处理一次,需要当前状态时读取action=status。