用 Webhook 自动化黄金和白银价格提醒
用签名 Webhook 自动推送金银价格提醒:提醒条件与冷却时间、负载格式、在 Node.js 和 Python 中验证 HMAC-SHA256 签名,以及重试与幂等处理。
用提醒代替轮询
许多集成只关心特定时刻的价格:黄金突破客户关注的价位、白银大幅下跌,或者某种货币的波动大到需要重新为商品目录定价。为了捕捉这些时刻而每分钟轮询一次 /latest,每月大约要消耗 45,000 次请求,而且您还得自己编写检测价格穿越阈值的逻辑。
MetalAPI 价格提醒把这部分工作交给了服务器。提醒引擎会在实时价格到达时逐一检查所有生效中的提醒,并在条件满足时通知您。提醒检查不消耗您的 API 配额,您的代码也只在有事发生时才会运行。
| 套餐 | 提醒数量 | 投递方式 |
|---|---|---|
| Free | 无 | — |
| Lite | 3 | 电子邮件 |
| Standard | 10 | 电子邮件 |
| Advanced | 25 | 电子邮件或 Webhook |
| Pro | 50 | 电子邮件或 Webhook |
| Ultra | 100 | 电子邮件或 Webhook |
| Business | 200 | 电子邮件或 Webhook |
| Enterprise | 500 | 电子邮件或 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 请求
每次投递的请求如下所示:
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 发送。以下三个细节决定了验证能否在生产环境中正常工作:
- 对原始字节计算哈希。 先解析 JSON 再重新序列化,可能会改变空白或数字格式,导致签名不匹配。请以 Buffer 形式读取请求体。
- 检查请求头中的时间戳。 使用请求头中的
t,而不是正文中的created。每次重试都会在发送时以新的t重新签名,而created始终保留原始时间。基于created的重放窗口会错误地拒绝合法的重试。 - 轮换期间同时接受两个密钥。 在控制台中轮换后,签名会立即改用新密钥。请在旧密钥旁边加上新密钥并完成部署;在此期间失败的投递会在几分钟内重试。确认新密钥正常工作后,再移除旧密钥。
在 Express 中,express.raw() 会以 Buffer 的形式提供请求体:
// 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 — 先 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 为主键的表:
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 服务器中仅打印日志的占位实现:
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 套餐上试用电子邮件提醒。
继续阅读
黄金价格 API:开发者完全指南
黄金价格 API 使用指南:获取密钥,用 curl、JavaScript 和 Python 发起调用,正确解读汇率格式,并妥善处理历史数据、错误、配额与缓存。
如何在网站上添加实时金价小组件
在 WordPress、Shopify 或纯 HTML 页面中添加实时金价小组件:在 MetalAPI 控制台中创建小组件,选择品种、布局和主题,然后粘贴两行代码即可完成。
K 金价格详解:24K、22K、18K 及其计算方法
24K、22K 和 18K 分别代表什么,916、750 等成色印记如何与 K 数对应,怎样计算每克 K 金价格,以及如何通过金价 API 直接获取各 K 数的金价。