事件与 Webhook

订单和余额的所有变化都会以事件形式推送到您的地址。事件共有三种。

更新于 2026年10月8日 · 阅读约需 5 分钟
Markdown

事件

订单1
order.updated订单已变更——事件中包含完整订单,不含商品
余额2
balance.updated余额已变更——事件中包含该笔操作和余额
balance.low余额低于您设置的阈值

此外还有 webhook.test:调用 POST /v1/webhook/test 时发送的测试事件。

事件结构

{
  "event_id": "evt_1042",    
  "event": "order.updated",  
  "created_at": "2026-09-24T10:14:05Z",
  "sandbox": false,          
  "order": { … }             
}
  1. event_id — 去重键。请求头 X-Astrum-Event-Id 中也有该值,但该请求头不受签名保护:请按已验证请求体中的 event_id 去重
  2. event — 发生了什么:order.updated、balance.updated 或 balance.low
  3. sandbox — 沙盒事件为 true
  4. order — 完整订单,结构与 GET /v1/orders/{order_id} 相同,但不含商品。余额事件中以 balance(与 GET /v1/balance 相同)代替,balance.updated 还会带有 operation(一条余额流水记录)

事件中始终是当前状态

请求体在发送时生成:旧事件重试时携带的也是最新的订单。因此投递顺序并不重要,只需保存收到的内容即可。

配置 Webhook

  1. 1. 地址
    PUT /v1/webhook,请求体为 {"url": "https://…"}:所有事件都会推送到该地址。第一个签名密钥只显示一次,即在第一次 PUT 的响应中。设置地址的密钥必须同时有权查看订单和余额。
  2. 2. 测试
    POST /v1/webhook/test 会发送测试事件 webhook.test。
  3. 3. 轮换
    POST /v1/webhook/rotate-secret:新密钥只显示一次,旧密钥在 24 小时内仍然有效。

地址未被接受时返回 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 联系我们