MetalAPI
XAU3,997.89−1.54%XAG53.936−1.51%XPT1,554.47−0.83%XPD1,047.03−0.44%XCU6.5080+2.29%WTI103.27+3.82%BRENT111.70−1.30%EUR1.120400−0.01%GBP1.337500−0.01%JPY0.0063761+0.14%CHF1.213445−0.25%TRY0.020125−0.12%BTC80,329.75−0.55%ETH2,329.36−1.13%
全部文章
Webhook价格提醒教程

用 Webhook 自动化黄金和白银价格提醒

用签名 Webhook 自动推送金银价格提醒:提醒条件与冷却时间、负载格式、在 Node.js 和 Python 中验证 HMAC-SHA256 签名,以及重试与幂等处理。

作者:MetalAPI Team··阅读约 9 分钟

用提醒代替轮询

许多集成只关心特定时刻的价格:黄金突破客户关注的价位、白银大幅下跌,或者某种货币的波动大到需要重新为商品目录定价。为了捕捉这些时刻而每分钟轮询一次 /latest,每月大约要消耗 45,000 次请求,而且您还得自己编写检测价格穿越阈值的逻辑。

MetalAPI 价格提醒把这部分工作交给了服务器。提醒引擎会在实时价格到达时逐一检查所有生效中的提醒,并在条件满足时通知您。提醒检查不消耗您的 API 配额,您的代码也只在有事发生时才会运行。

套餐提醒数量投递方式
Free无—
Lite3电子邮件
Standard10电子邮件
Advanced25电子邮件或 Webhook
Pro50电子邮件或 Webhook
Ultra100电子邮件或 Webhook
Business200电子邮件或 Webhook
Enterprise500电子邮件或 Webhook

提醒在控制台的 Alerts 中管理。Webhook 指南是简明参考;本文则介绍在生产环境中运行接收端点所需了解的细节。如果您需要的是每一次价格更新而不是阈值事件,可以阅读实时流推送一文,了解另一种方案。

提醒条件与触发时机

每个提醒监控一个品种在一种货币下的价格,例如以 USD 计价的 XAU,或以 EUR 计价的 XAG。任何有效品种都可以使用,包括铂金、钯金、工业金属、能源、货币和加密货币。共有四种条件:

条件触发时机阈值
above价格 ≥ 阈值以提醒所用货币表示的价格
below价格 ≤ 阈值以提醒所用货币表示的价格
change_pct_up相对上一收盘价的涨幅 ≥ 阈值小于 100 的百分比
change_pct_down相对上一收盘价的变化 ≤ −阈值小于 100 的百分比,以正数输入

金属的阈值按标准单位金衡盎司计算,铜等工业金属也是如此,尽管它们在其他地方常以磅报价。能源使用其合约单位,例如原油按桶计价,Webhook 负载中也始终会注明单位。

价格和涨跌幅均以提醒所用的货币计算。对于以 EUR 计价的 XAG,即使白银的美元价格没有变化,仅仅欧元走强也可能使白银跌破您的阈值,或触发 change_pct_down。

提醒在价格穿越阈值时触发,而不是在条件成立期间的每次价格更新时都触发。黄金向上突破 $4,000 时,您只会收到一次通知。条件再次变为不成立后,提醒会重新启用,因此之后价格跌回该价位以下、再次向上突破时,您会再次收到通知。

冷却时间是同一提醒两次通知之间的最短间隔:可设为 5 分钟到 7 天(10,080 分钟),默认为 60 分钟。在阈值附近徘徊的价格可能在一小时内多次穿越阈值,冷却时间会将这些穿越合并为一条通知。对于只需关注一次的价位,请设置较长的冷却时间,并在提醒触发后将其暂停。

有一个特性在测试时很有用。新建的提醒没有之前的状态,因此如果它的条件已经成立,就会在下一次价格更新时触发,在市场交易期间通常不超过一分钟。创建一个 XAU 高于 1 USD、冷却时间为 5 分钟的提醒,就能得到一次真实的投递用于测试。测试完成后请将其删除。

电子邮件或 Webhook 投递

电子邮件提醒从 Lite 套餐起提供,面向的是人:邮件主题形如“MetalAPI alert: Gold (XAU) rose above 4000 USD”,正文包含当前价格以及您为该提醒添加的备注。

Webhook 从 Advanced 套餐起提供,面向的是系统:向您指定的 URL 发送一个带 JSON 正文的 HTTPS POST 请求,并附有签名,以便您证明它确实来自 MetalAPI。Webhook URL 必须满足以下条件:

  • 使用 https://;
  • 解析到公网 IP 地址(私有地址、环回地址和链路本地地址段会在保存提醒时被拒绝,并在投递时再次检查);
  • 不包含用户名或密码。

系统不会跟随重定向,因此请填写最终的 URL。如果账户切换到不支持 Webhook 的套餐,其 Webhook 提醒会改为通过电子邮件发送,而不会被丢弃。

一个账户的所有 Webhook 都使用同一个以 whsec_ 开头的密钥签名。您可以在 Alerts 页面的 Webhook signing secret 面板中创建、查看、复制和轮换该密钥。如果尚未创建,系统会在第一次投递 Webhook 时自动生成,之后即可在面板中看到。轮换会立即生效,这一点对下文的验证代码很重要。

Webhook 请求

每次投递的请求如下所示:

http
POST /webhooks/metalapi HTTP/1.1
Content-Type: application/json
User-Agent: MetalAPI-Webhooks/1.0
X-MetalAPI-Timestamp: 1792765800
X-MetalAPI-Signature: t=1792765800,v1=5f0c1e…9a2b

{
  "event": "alert.triggered",
  "id": 18342,
  "created": 1792765800,
  "alert": {
    "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    "symbol": "XAU",
    "base": "USD",
    "condition": "above",
    "threshold": 4000,
    "note": "Gold above 4k"
  },
  "price": 4001.25,
  "unit": "troy_oz"
}
字段含义
event目前始终为 alert.triggered。请忽略未知的值,这样今后新增的事件类型就不会导致处理程序出错。
id数字事件 ID,每次提醒触发唯一,重试时保持不变
created提醒触发时的 Unix 时间
alert提醒的配置:id(UUID)、symbol、base、condition、threshold 和 note
price触发提醒的价格,单位为每 unit 多少 base
unit金属为 troy_oz,能源为 bbl、mmbtu 或 gal,货币为 currency,加密货币为 coin

note 字段最多 200 个字符,很适合存放您在控制台中设置的路由信息,例如团队名称、频道或商品 SKU。

在 Node.js 中验证签名

X-MetalAPI-Signature 请求头的格式为 t=<timestamp>,v1=<signature>。签名是以您的 whsec_ 密钥为密钥,对字符串 <timestamp>.<raw body> 计算出的 HMAC-SHA256 值,采用小写十六进制编码。时间戳还会单独通过 X-MetalAPI-Timestamp 发送。以下三个细节决定了验证能否在生产环境中正常工作:

  1. 对原始字节计算哈希。 先解析 JSON 再重新序列化,可能会改变空白或数字格式,导致签名不匹配。请以 Buffer 形式读取请求体。
  2. 检查请求头中的时间戳。 使用请求头中的 t,而不是正文中的 created。每次重试都会在发送时以新的 t 重新签名,而 created 始终保留原始时间。基于 created 的重放窗口会错误地拒绝合法的重试。
  3. 轮换期间同时接受两个密钥。 在控制台中轮换后,签名会立即改用新密钥。请在旧密钥旁边加上新密钥并完成部署;在此期间失败的投递会在几分钟内重试。确认新密钥正常工作后,再移除旧密钥。

在 Express 中,express.raw() 会以 Buffer 的形式提供请求体:

webhook-server.mjs
// webhook-server.mjs — Node.js 18+,需先 npm install express
import crypto from "node:crypto";
import express from "express";

// 以逗号分隔:当前密钥,轮换期间再加上旧密钥。
const SECRETS = (process.env.METALAPI_WEBHOOK_SECRETS ?? "").split(",").filter(Boolean);
const TOLERANCE_S = 300; // 拒绝超过 5 分钟的签名

export function verifySignature(rawBody, header) {
  const parts = {};
  for (const item of (header ?? "").split(",")) {
    const i = item.indexOf("=");
    if (i > 0) parts[item.slice(0, i).trim()] = item.slice(i + 1).trim();
  }
  const ts = parts.t ?? "";
  if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_S) return false;
  if (!/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
  const given = Buffer.from(parts.v1, "hex");
  return SECRETS.some((secret) => {
    const expected = crypto.createHmac("sha256", secret).update(`${ts}.`).update(rawBody).digest();
    return crypto.timingSafeEqual(expected, given);
  });
}

async function handleAlert(event) {
  const { alert, price, unit } = event;
  console.log(`${alert.symbol}/${alert.base} ${alert.condition} ${alert.threshold}: ${price} per ${unit}`);
}

const app = express();

app.post("/webhooks/metalapi", express.raw({ type: "application/json", limit: "64kb" }), (req, res) => {
  if (!Buffer.isBuffer(req.body) || !verifySignature(req.body, req.get("X-MetalAPI-Signature"))) {
    return res.status(401).send("invalid signature");
  }
  const event = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(204); // 在 8 秒内响应,然后再处理业务
  if (event.event === "alert.triggered") {
    handleAlert(event).catch((err) => console.error("alert handler failed:", err));
  }
});

app.listen(3000);

处理程序在开始处理业务之前就先作出响应。如果这些业务很重要,请先将事件持久化保存(后面几节会介绍具体做法),这样即使在响应之后发生崩溃,事件也不会丢失。

在 Python 中验证签名

在 Python 中实现同样的检查只需标准库。hmac.compare_digest 提供恒定时间比较:

webhook_app.py
# webhook_app.py — 先 pip install flask,再运行:flask --app webhook_app run --port 3000
import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

# 以逗号分隔:当前密钥,轮换期间再加上旧密钥。
SECRETS = [s for s in os.environ.get("METALAPI_WEBHOOK_SECRETS", "").split(",") if s]
TOLERANCE_S = 300  # 拒绝超过 5 分钟的签名

app = Flask(__name__)


def verify_signature(raw: bytes, header: str) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    ts, given = parts.get("t", ""), parts.get("v1", "")
    if not ts.isdigit() or abs(time.time() - int(ts)) > TOLERANCE_S:
        return False
    signed = ts.encode() + b"." + raw
    return any(
        hmac.compare_digest(hmac.new(s.encode(), signed, hashlib.sha256).hexdigest(), given)
        for s in SECRETS
    )


@app.post("/webhooks/metalapi")
def metalapi_webhook():
    raw = request.get_data()  # 原始字节,尚未经过任何 JSON 解析
    if not verify_signature(raw, request.headers.get("X-MetalAPI-Signature", "")):
        abort(401)
    event = json.loads(raw)
    if event.get("event") == "alert.triggered":
        alert = event["alert"]
        # 在此将事件交给任务队列,然后尽快返回。
        app.logger.warning("%s/%s %s %s: %s", alert["symbol"], alert["base"],
                           alert["condition"], alert["threshold"], event["price"])
    return "", 204

其他框架的区别只在于如何读取原始请求体:FastAPI 中为 await request.body(),Django 中为 request.body。无论使用哪种框架,都要确保在处理程序运行之前,没有中间件已经解析并消耗了请求体。

重试、重复投递与响应时间

如果您的端点在 8 秒内返回任意 2xx 状态码,投递即视为成功。超时、连接错误、重定向以及其他所有状态码都视为失败,并会重试:

尝试次序时间
1提醒触发后立即发送
2提醒触发后约 1 分钟
3提醒触发后约 4 分钟

三次尝试之后,该事件会被标记为失败。Alerts 页面上的 Recent deliveries 表格列出了每个事件及其结果,包括最后一次尝试的 HTTP 状态码或错误信息。端点出现异常时,这里是首先要查看的地方。

重试会沿用同一个事件 id。如果您的服务器已经处理了某次投递,但响应时间超过 8 秒,MetalAPI 这边会将其视为失败并再次投递,因此处理程序必须是幂等的。最简单的做法是使用一张以事件 ID 为主键的表:

PostgreSQL
create table metalapi_events (
  event_id     bigint primary key,      -- 负载中的 "id",重试时保持不变
  alert_id     uuid not null,
  payload      jsonb not null,
  received_at  timestamptz not null default now(),
  processed_at timestamptz
);

-- 在 Webhook 处理程序中,验证签名之后、响应之前执行:
insert into metalapi_events (event_id, alert_id, payload)
values ($1, $2, $3)
on conflict (event_id) do nothing;

插入事件,返回 204,再由后台 worker 处理 processed_at 为空的行。重复的投递会命中主键冲突,不会产生任何变化。插入操作只需几毫秒,即使后续处理很慢,也能轻松保证在 8 秒的时限内完成响应。

将提醒转发到聊天工具和其他系统

一个常见用法是把提醒发布到团队聊天中。Slack 和 Discord 都支持传入 Webhook,但不要将 MetalAPI 提醒直接指向它们:它们需要不同的负载格式,而且您会失去签名验证。请先在您自己的端点上完成验证,再格式化并转发。下面这个版本的 handleAlert 可替换上文 Node.js 服务器中仅打印日志的占位实现:

转发到聊天 Webhook
const VERBS = { above: "rose above", below: "fell below", change_pct_up: "is up", change_pct_down: "is down" };

async function handleAlert({ alert, price, unit }) {
  const level = alert.condition.startsWith("change_pct")
    ? `${alert.threshold}% since the previous close`
    : `${alert.threshold} ${alert.base}`;
  const per = unit && unit !== "currency" ? ` per ${unit}` : "";
  const text = `${alert.symbol} ${VERBS[alert.condition]} ${level}: now ${price} ${alert.base}${per}` +
    (alert.note ? ` (${alert.note})` : "");

  // Slack 传入 Webhook 需要 {"text": ...};Discord Webhook 需要 {"content": ...}。
  const body = process.env.CHAT_KIND === "discord" ? { content: text } : { text };
  const res = await fetch(process.env.CHAT_WEBHOOK_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(10_000),
  });
  if (!res.ok) throw new Error(`chat webhook returned ${res.status}`);
}

对于上面的示例负载,消息内容为“XAU rose above 4000 USD: now 4001.25 USD per troy_oz (Gold above 4k)”。同样的模式适用于任何提供 HTTP API 的系统:创建工单、在金价单日波动超过 2% 时刷新网店中缓存的商品价格,或者通知负责维护价格数据源的同事。请在后台 worker 而不是请求处理程序中执行转发,这样即使聊天服务响应缓慢,也绝不会导致投递超时。

如果不想等待市场行情来测试端点,可以用自己的密钥为一个示例负载签名:

发送已签名的测试事件
SECRET="whsec_your_secret"
BODY='{"event":"alert.triggered","id":1,"created":1792765800,"alert":{"id":"00000000-0000-0000-0000-000000000000","symbol":"XAU","base":"USD","condition":"above","threshold":4000,"note":"test"},"price":4001.25,"unit":"troy_oz"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')

curl -i -X POST http://localhost:3000/webhooks/metalapi \
  -H "Content-Type: application/json" \
  -H "X-MetalAPI-Timestamp: $TS" \
  -H "X-MetalAPI-Signature: t=$TS,v1=$SIG" \
  --data-raw "$BODY"

返回 204 即表示签名检查通过。如需进行端到端测试,请按上文所述创建一个条件已经成立的提醒,然后在 Recent deliveries 中查看事件出现。支持 Webhook 投递的提醒从 Advanced 套餐起提供;各套餐详情请参见定价,也可以先创建账户,在 Lite 套餐上试用电子邮件提醒。

用您自己的数据试一试

创建免费账户,两分钟内即可发起第一次请求。每月 100 次请求,无需信用卡。

继续阅读