集成事件与 Webhook
事件与 Webhook
订单和余额的所有变化都会以事件形式推送到您的地址。事件共有三种。
更新于 2026年10月8日 · 阅读约需 5 分钟
事件
订单1
order.updated订单已变更——事件中包含完整订单,不含商品余额2
balance.updated余额已变更——事件中包含该笔操作和余额balance.low余额低于您设置的阈值此外还有 webhook.test:调用 POST /v1/webhook/ 时发送的测试事件。
事件结构
{
"event_id": "evt_1042",
"event": "order.updated",
"created_at": "2026-09-24T10:14:05Z",
"sandbox": false,
"order": { … }
}event_id— 去重键。请求头X-Astrum-Event-Id中也有该值,但该请求头不受签名保护:请按已验证请求体中的event_id去重event— 发生了什么:order.updated、balance.updated或balance.lowsandbox— 沙盒事件为trueorder— 完整订单,结构与GET /v1/orders/相同,但不含商品。余额事件中以{order_id} balance(与GET /v1/balance相同)代替,balance.updated还会带有operation(一条余额流水记录)
事件中始终是当前状态
请求体在发送时生成:旧事件重试时携带的也是最新的订单。因此投递顺序并不重要,只需保存收到的内容即可。
配置 Webhook
- 1. 地址
PUT /v1/webhook,请求体为{"url": "https://…"}:所有事件都会推送到该地址。第一个签名密钥只显示一次,即在第一次PUT的响应中。设置地址的密钥必须同时有权查看订单和余额。 - 2. 测试
POST /v1/webhook/会发送测试事件test webhook.test。 - 3. 轮换
POST /v1/webhook/:新密钥只显示一次,旧密钥在 24 小时内仍然有效。rotate-secret
地址未被接受时返回 422 70002 webhook_url_invalid:问题描述在 message 中,机器可读的原因在 details.problem 中:
| details.problem | 问题所在(message) |
|---|---|
bad_url |
无法解析地址:需要类似 https://example.com/hook 的完整地址,不能包含空格、登录信息和 # |
not_https |
需要 https:// 地址 |
bad_port |
端口不可用,仅允许:443, 8443 |
forbidden_host |
不能发送到该地址:它属于我们或我们的供应商,请填写您自己的服务器 |
private_address |
该地址指向内部网络,需要公网地址 |
unresolvable |
域名无法解析为地址,请检查 DNS |
投递与重试
至少投递一次
同一事件可能收到两次:请按经签名验证的请求体中的 event_id 去重。
不保证顺序
事件中始终是当前状态;遗漏的事件可通过 GET /v1/orders 补齐(其他日期用 ?date=)。
5 秒内返回 2xx
耗时的处理请放在响应之后,否则会触发重试。
仅限公网 https
端口 443 或 8443;内网地址和我们供应商的主机会返回 422 70002 webhook_url_invalid。
失败后重试
失败1 分钟5 分钟30 分钟2 小时12 小时24 小时停止
最后一次重试后不再推送该事件:我们会收到告警,您会收到发往账户邮箱的邮件。订单状态可通过 GET /v1/orders 查询。
order.updated关于登录链接(由我方银行卡代付):每 15 秒推送一次,共 12 次——链接只在几分钟内有效签名
X-Astrum-Signature: t=<unix>,v1=<hex>
t — 发送时间,unix 时间戳
v1 — 用签名密钥对字符串
f"{t}." + 原始请求体 计算的 HMAC-SHA256基于请求体的原始字节验证,而不是重新序列化的 JSON
使用恒定时间比较
校验时间窗口
轮换后 24 小时内会带有两个签名(
v1=…,v1=…),任意一个验证通过即可import hashlib
import hmac
import re
import time
_T = re.compile(r"[0-9]{1,12}")
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
pairs = [part.split("=", 1) for part in header.split(",") if "=" in part]
t = next((v for k, v in pairs if k.strip() == "t"), "")
if not _T.fullmatch(t) or abs(time.time() - int(t)) > tolerance:
return False
signed = t.encode("ascii") + b"." + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest().encode("ascii")
return any(
hmac.compare_digest(expected, v.strip().encode("utf-8"))
for k, v in pairs
if k.strip() == "v1"
)
import crypto from 'node:crypto'
export function verify(rawBody: Buffer, header: string | undefined, secret: string, toleranceSec = 300): boolean {
const pairs = String(header || '')
.split(',')
.map((part) => part.split('='))
.filter((pair) => pair.length === 2)
const t = (pairs.find(([k]) => k.trim() === 't') || [])[1] || ''
if (!/^[0-9]{1,12}$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false
const expected = Buffer.from(crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex'))
return pairs.some(([k, v]) => {
if (k.trim() !== 'v1') return false
const got = Buffer.from(v.trim())
return got.length === expected.length && crypto.timingSafeEqual(got, expected)
})
}
function astrum_verify(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
$t = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
$kv = explode('=', trim($part), 2);
if (count($kv) !== 2) {
continue;
}
if ($kv[0] === 't') {
$t = $kv[1];
} elseif ($kv[0] === 'v1') {
$signatures[] = $kv[1];
}
}
if ($t === null || !preg_match('/^[0-9]{1,12}$/', $t) || abs(time() - (int) $t) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
事件示例
示例:order.updated(客户信息已接收)
{
"event_id": "evt_1042",
"event": "order.updated",
"created_at": "2026-09-28T10:15:12Z",
"sandbox": false,
"order": {
"order_id": "b1e04c6e-5a0b-4f6e-9a39-0b9f2f1c8d21",
"product_id": "3f6c0e7e-2b1d-4c1a-9a57-4d0f1b8e2c11",
"product_name": "ChatGPT Plus",
"delivery_method": "activation",
"subscription_days": 30,
"status": "processing",
"status_text": "正在开通订阅,通常只需一两分钟。",
"price_usdt": "20.00",
"refunded_usdt": null,
"created_at": "2026-09-28T10:14:03Z",
"available_actions": [],
"activation": {
"customer_data_type": null,
"customer_instruction": null,
"verification_link": null,
"verification_until": null,
"current_plan_until": null,
"note_to_customer": null,
"page_url": "https://activate.astrum.shop/#t=seBMbloLT26aOQufLxyNIfi1NV8Ia9Gl8a4-FyM75pc"
},
"warranty": {
"until": null,
"claim_id": null,
"claim_status": null
}
}
}
本页内容是否有帮助?
发现错误?在 Telegram 联系我们