菜单

价格提醒Webhook

当黄金、白银或铜的价格跨过指定价位、按指定百分比变动,或当日收盘时,你的URL会收到带签名的POST请求。在控制台中添加提醒,验证每个请求后再进行处理。

Plus · $39/moPro · $89/mo

提醒与限制

每个提醒按一条规则监测一种金属的现货价格,黄金、白银或铜,并发送到一个URL。Plus和Pro各包含5个提醒。Free和Basic不包含价格提醒。如果套餐到期或付款失败,发送会暂停,直到套餐重新生效。

  • 跨越价位:价格从指定价位的一侧移动到另一侧,可设为向上、向下或双向。触发后,至少10分钟内不再通知。如果价格在此期间反向跨过价位后又返回,第二次跨越不会发送。只有该时间已过,且价格已反向跨过价位,回到触发前所在的一侧并距该价位达到0.1%,提醒才会重新就绪。这样既能通知真正的再次跨越,也能过滤价位附近的小幅波动。
  • 百分比变动:价格在1小时或24小时内变动指定百分比。触发一次后,需经过1小时,且变动幅度已回落到阈值以下,提醒才可再次触发。每周开市后不久的24小时变动以重新开市时的价格为基准。周末的价格跳空绝不会计入变动。
  • 每日收盘:UTC当天的最后一个价格,在UTC午夜刚过时发送。如果某种金属当天没有价格,例如周末,则没有收盘价。

日期以UTC为准。每日收盘提醒每天发送一次。价格在指定价位附近波动时,不会每次价格更新都发送请求。

可接收的事件

  • price.level_cross:价格已跨过你设置的价位。
  • price.percent_move:价格已按你设置的百分比变动。
  • price.daily_close:UTC当天已结束。
  • webhook.test:你点击了某个提醒的Send test。其data为{"alert_id":"<alert id>"}。每个提醒每分钟可发送一次测试,同时最多可有20个测试等待发送。

读取请求

你的URL会收到POST请求。URL必须使用https://、端口443和公共主机名,不得包含用户名或密码,长度不得超过2048个字符。每次发送前都会检查地址。不跟随重定向,3xx响应会被视为失败。系统只读取响应状态码。

Content-Type: application/jsonUser-Agent: webhook-delivery/1Webhook-Signature: t=<unix seconds>,v1=<hex>

请求体为JSON,包含四个顶层字段:id、type、created_at和data。实际请求体中的键顺序和空白可能与这里的示例不同。

idstring
唯一的事件标识符,用于跳过重复事件。
typestring
上述事件类型之一。
created_atstring
以Z结尾的ISO 8601格式UTC时间。对于每日收盘事件,该值为已结束日期的23:59:59。
data.alert_idstring
触发的提醒。轮换密钥时不会改变。
data.kindstring
level_cross、percent_move或daily_close。
data.symbolstring
XAU-USD-SPOT、XAG-USD-SPOT或HG-USD-SPOT。
data.directionstring
提醒中设置的above、below或either。
data.pricestring
触发提醒的价格,以十进制字符串表示。仅用于价位和百分比事件。
data.observed_atstring
观测到价格的时间。仅用于价位和百分比事件。
data.levelstring
你设置的价位。仅用于价位事件。
data.percent, data.window, data.reference_pricestring
你设置的百分比、时间窗口(1h或24h)以及窗口开始时的价格。仅用于百分比事件。
data.date, data.closestring
UTC日期及当天最后一个价格。仅用于每日收盘事件。

请求体示例

price.level_cross

{
  "id": "evt_3f0c6a52-6a2e-4b8e-9c1d-2f4b7e9a1c03_level_cross:492841",
  "type": "price.level_cross",
  "created_at": "2026-10-23T14:02:11.482113Z",
  "data": {
    "alert_id": "3f0c6a52-6a2e-4b8e-9c1d-2f4b7e9a1c03",
    "kind": "level_cross",
    "symbol": "XAU-USD-SPOT",
    "direction": "above",
    "price": "4203.1",
    "level": "4200",
    "observed_at": "2026-10-23T14:02:11.482113+00:00"
  }
}

price.percent_move

{
  "id": "evt_8a1d52c4-0b7e-4f55-a3d9-61e2c7b8f410_percent_move:492855",
  "type": "price.percent_move",
  "created_at": "2026-10-23T09:15:40.120934Z",
  "data": {
    "alert_id": "8a1d52c4-0b7e-4f55-a3d9-61e2c7b8f410",
    "kind": "percent_move",
    "symbol": "XAU-USD-SPOT",
    "direction": "either",
    "price": "4168.8",
    "percent": "2",
    "window": "24h",
    "reference_price": "4083.05",
    "observed_at": "2026-10-23T09:15:40.120934+00:00"
  }
}

price.daily_close

{
  "id": "evt_c92e0f7b-31a4-4d6a-8e57-a05b3d1c92e8_close:2026-10-22",
  "type": "price.daily_close",
  "created_at": "2026-10-22T23:59:59Z",
  "data": {
    "alert_id": "c92e0f7b-31a4-4d6a-8e57-a05b3d1c92e8",
    "kind": "daily_close",
    "symbol": "XAG-USD-SPOT",
    "direction": "either",
    "date": "2026-10-22",
    "close": "48.62"
  }
}

webhook.test

{
  "id": "evt_test_1792143012345",
  "type": "webhook.test",
  "created_at": "2026-10-23T09:30:12.345000Z",
  "data": {
    "alert_id": "3f0c6a52-6a2e-4b8e-9c1d-2f4b7e9a1c03"
  }
}

验证签名

处理事件前,请检查Webhook-Signature。使用收到的原始请求体字节,不做任何改动。验证前切勿解析JSON后再将其序列化。

  1. 按逗号拆分Webhook-Signature,读取t和v1。
  2. 如果t比你的时钟快或慢超过300秒,则拒绝请求。
  3. 将t、英文句点和原始请求体拼接为消息,即<t>.后接请求体。
  4. 使用提醒的签名密钥(以whsec_开头)计算消息的HMAC-SHA256,并将结果编码为十六进制。
  5. 使用恒定时间比较将结果与v1比较。如果不一致,则拒绝请求。

Node.js

Python

import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const TOLERANCE_SECONDS = 300;

// rawBody: the request body exactly as received (a Buffer), before any JSON parsing.
function verifyWebhook(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
  const fields = {};
  for (const part of String(header ?? "").split(",")) {
    const i = part.indexOf("=");
    if (i > 0) fields[part.slice(0, i).trim()] = part.slice(i + 1).trim();
  }
  const t = Number(fields.t);
  if (!Number.isInteger(t) || Math.abs(now - t) > TOLERANCE_SECONDS) return false;
  const expected = createHmac("sha256", secret).update(t + ".").update(rawBody).digest();
  const given = Buffer.from(fields.v1 ?? "", "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}

const seen = new Set(); // use your database in production

createServer((req, res) => {
  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", () => {
    const rawBody = Buffer.concat(chunks);
    const header = req.headers["webhook-signature"];
    if (!verifyWebhook(rawBody, header, process.env.WEBHOOK_SECRET)) {
      res.writeHead(400).end();
      return;
    }
    const event = JSON.parse(rawBody);
    if (!seen.has(event.id)) {
      seen.add(event.id);
      // Hand the event to a queue or background job here, then answer quickly.
      console.log(event.type, event.data);
    }
    res.writeHead(204).end();
  });
}).listen(Number(process.env.PORT ?? 3000));
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify_webhook(raw_body: bytes, header: str, secret: str, now: int | None = None) -> bool:
    """raw_body is the request body exactly as received, before any JSON parsing."""
    fields = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
    try:
        t = int(fields["t"])
    except (KeyError, ValueError):
        return False
    now = int(time.time()) if now is None else now
    if abs(now - t) > TOLERANCE_SECONDS:
        return False
    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(fields.get("v1", "").encode(), expected.encode())


# Flask:  verify_webhook(request.get_data(), request.headers.get("Webhook-Signature", ""), secret)
# Django: verify_webhook(request.body, request.headers.get("Webhook-Signature", ""), secret)

响应与重试处理

在10秒内返回任意2xx状态码。3xx、4xx、5xx、超时或连接错误均视为一次失败。请保存事件并迅速返回响应,将耗时任务放到后台处理。

  • 第一次重试:首次失败后1分钟。
  • 第二次重试:上次失败后5分钟。
  • 第三次重试:上次失败后30分钟。
  • 第四次重试:上次失败后2小时。
  • 第五次,也是最后一次重试:上次失败后6小时。

总共在约8.5小时内尝试6次。最后一次尝试失败后,该事件不会再发送,也不会通过电子邮件进行备用通知。每次尝试都会使用新的时间戳重新签名。首次尝试发送时,如果事件已超过一小时,则会被丢弃。发送记录保留30天。

轮换签名密钥

密钥仅在添加提醒时显示一次。如果怀疑密钥泄露或已将其丢失,请在控制台的提醒中选择Rotate secret。新密钥也只显示一次。提醒保留原有标识符,旧密钥立即失效,此后的发送均使用新密钥。为避免发送失败,请在切换期间让服务器同时接受两个密钥,持续几分钟。

常见问题

价格提醒需要哪个套餐?

Plus ($39/月) 和Pro ($89/月) 套餐均包含价格提醒功能,每个套餐各有5个提醒。Free套餐和Basic套餐均不包含价格提醒功能。

单个提醒多久能触发一次?

价位提醒触发后,至少10分钟内不再通知,期间的跨越不会发送。只有该时间已过,且价格已反向跨过价位,回到触发前所在的一侧并距该价位达到0.1%,提醒才会重新就绪。百分比提醒触发一次后,经过1小时且变动消失,才能再次触发。每日收盘按UTC日期每天发送一次。

如果我的服务器宕机了,会怎样?

发送失败后,会在约8.5小时内最多再尝试5次。最后一次尝试失败后,该事件不会再发送,也不会通过电子邮件进行备用通知。

同一个事件可能收到两次吗?

可能。即使你已经处理了事件,也可能重试发送,例如你的响应在10秒后才到达。请保存已处理的事件标识符,并跳过重复事件。

另请参阅