Automate Gold and Silver Price Alerts with Webhooks
Automate gold and silver price alerts with signed webhooks: alert conditions, cooldowns, the payload, HMAC-SHA256 verification in Node and Python, and retries.
Alerts instead of polling
Many integrations only care about prices at specific moments: when gold crosses a level a customer asked about, when silver drops sharply, or when a currency moves enough to reprice a catalogue. Polling /latest once a minute to catch those moments costs about 45,000 requests a month, and you still have to write the logic that detects the crossing.
MetalAPI price alerts move that work to the server. The alert engine checks every active alert against live prices as they arrive and notifies you when a condition is met. Alert checks do not use your API quota, and your code only runs when something happens.
| Plan | Alerts | Delivery |
|---|---|---|
| Free | None | — |
| Lite | 3 | |
| Standard | 10 | |
| Advanced | 25 | Email or webhook |
| Pro | 50 | Email or webhook |
| Ultra | 100 | Email or webhook |
| Business | 200 | Email or webhook |
| Enterprise | 500 | Email or webhook |
You manage alerts under Alerts in the dashboard. The webhooks guide is the short reference; this post covers the details you need for an endpoint that runs in production. If you need every price update rather than threshold events, the realtime streaming post covers the other approach.
Alert conditions and when they fire
Each alert watches one symbol priced in one currency, for example XAU in USD or XAG in EUR. Any active symbol works, including platinum, palladium, industrial metals, energy, currencies and crypto. There are four conditions:
| Condition | Fires when | Threshold |
|---|---|---|
above | price ≥ threshold | A price in the alert's currency |
below | price ≤ threshold | A price in the alert's currency |
change_pct_up | change since the previous close ≥ threshold | A percentage below 100 |
change_pct_down | change since the previous close ≤ −threshold | A percentage below 100, entered as a positive number |
Metal thresholds are per troy ounce, the canonical unit. That includes industrial metals such as copper, which are often quoted per pound elsewhere. Energy uses its contract unit, a barrel for crude oil, and the webhook payload always states the unit.
Prices and percentage changes are computed in the alert's currency. For XAG in EUR, a stronger euro alone can push silver below your threshold or trigger change_pct_down, even if silver is unchanged in US dollars.
Alerts fire on the crossing, not on every price update while the condition is true. When gold rises through $4,000, you get one notification. The alert re-arms once the condition has been false again, so a later dip below the level followed by a new rise through it notifies you again.
The cooldown is the minimum time between two notifications from the same alert: anywhere from 5 minutes to 7 days (10,080 minutes), with a default of 60 minutes. A price that hovers around a threshold can cross it many times in an hour, and the cooldown turns that into a single message. For a level that only matters once, use a long cooldown and pause the alert after it fires.
One behaviour is handy for testing. A new alert has no previous state, so if its condition is already true, it fires with the next price update, usually within a minute while the market is trading. An alert for XAU above 1 USD with a 5-minute cooldown gives you a real delivery to test against. Delete it afterwards.
Email or webhook delivery
Email alerts are available from the Lite plan. They are meant for people: a subject such as “MetalAPI alert: Gold (XAU) rose above 4000 USD”, the current price and the note you attached to the alert.
Webhooks, available from the Advanced plan, are meant for systems: an HTTPS POST with a JSON body to a URL you choose, signed so that you can prove it came from MetalAPI. A webhook URL must:
- use
https://, - resolve to a public IP address (private, loopback and link-local ranges are rejected when you save the alert and checked again at delivery time),
- contain no username or password.
Redirects are not followed, so enter the final URL. If an account moves to a plan without webhooks, its webhook alerts are delivered by email instead of being dropped.
All webhooks of an account are signed with one secret that starts with whsec_. You can create, reveal, copy and rotate it in the Webhook signing secret panel on the Alerts page. If you have not created one, it is generated with the first webhook delivery and then shown in the panel. Rotation takes effect immediately, which matters for the verification code below.
The webhook request
Every delivery looks like this:
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"
}| Field | Meaning |
|---|---|
event | Currently always alert.triggered. Ignore unknown values so that new event types never break your handler. |
id | Numeric event ID, unique per fired alert and identical across retries |
created | Unix time at which the alert fired |
alert | The alert as configured: id (a UUID), symbol, base, condition, threshold and note |
price | The price that triggered the alert, in base per unit |
unit | troy_oz for metals, bbl, mmbtu or gal for energy, currency for currencies, coin for crypto |
The note field, up to 200 characters, is a convenient place for routing information you set in the dashboard, such as a team name, a channel or a product SKU.
Verifying the signature in Node.js
The X-MetalAPI-Signature header has the form t=<timestamp>,v1=<signature>. The signature is the lowercase hex HMAC-SHA256 of the string <timestamp>.<raw body>, keyed with your whsec_ secret. The timestamp is also sent on its own in X-MetalAPI-Timestamp. Three details decide whether a check works in production:
- Hash the raw bytes. Parsing the JSON and serialising it again can change whitespace or number formatting and break the signature. Read the body as a buffer.
- Check the header timestamp. Use
tfrom the header, notcreatedfrom the body. Each retry is signed again at send time with a newt, whilecreatedkeeps the original time. A replay window based oncreatedwould reject valid retries. - Accept two secrets during a rotation. Rotating in the dashboard switches signing to the new secret at once. Add the new secret next to the old one and deploy; deliveries that fail in the meantime are retried for a few minutes. Remove the old secret once the new one works.
With Express, express.raw() provides the body as a Buffer:
// webhook-server.mjs — Node.js 18+, npm install express
import crypto from "node:crypto";
import express from "express";
// Comma-separated: the current secret, plus the previous one during a rotation.
const SECRETS = (process.env.METALAPI_WEBHOOK_SECRETS ?? "").split(",").filter(Boolean);
const TOLERANCE_S = 300; // reject signatures older than 5 minutes
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); // answer within 8 seconds, then do the work
if (event.event === "alert.triggered") {
handleAlert(event).catch((err) => console.error("alert handler failed:", err));
}
});
app.listen(3000);The handler answers before doing any work. If the work is important, store the event durably first (the next sections show how), so that a crash after the response cannot lose it.
Verifying the signature in Python
The same check in Python uses only the standard library. hmac.compare_digest provides the constant-time comparison:
# webhook_app.py — pip install flask, then: flask --app webhook_app run --port 3000
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
# Comma-separated: the current secret, plus the previous one during a rotation.
SECRETS = [s for s in os.environ.get("METALAPI_WEBHOOK_SECRETS", "").split(",") if s]
TOLERANCE_S = 300 # reject signatures older than 5 minutes
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() # raw bytes, before any JSON parsing
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"]
# Hand the event to a job queue here and return quickly.
app.logger.warning("%s/%s %s %s: %s", alert["symbol"], alert["base"],
alert["condition"], alert["threshold"], event["price"])
return "", 204Other frameworks only differ in how you read the raw body: await request.body() in FastAPI and request.body in Django. Whatever the framework, make sure no middleware has parsed and consumed the body before your handler runs.
Retries, duplicates and response times
A delivery succeeds when your endpoint answers with any 2xx status within 8 seconds. Timeouts, connection errors, redirects and every other status count as failures and are retried:
| Attempt | When |
|---|---|
| 1 | As soon as the alert fires |
| 2 | About 1 minute after the alert fired |
| 3 | About 4 minutes after the alert fired |
After three attempts the event is marked as failed. The Recent deliveries table on the Alerts page lists every event with its result, including the HTTP status or error message of the last attempt. It is the first place to look when an endpoint misbehaves.
Retries reuse the same event id. A delivery that your server processed but answered after more than 8 seconds counts as failed on MetalAPI's side and arrives again, so the handler must be idempotent. The simplest way is a table keyed by the event ID:
create table metalapi_events (
event_id bigint primary key, -- payload "id", identical across retries
alert_id uuid not null,
payload jsonb not null,
received_at timestamptz not null default now(),
processed_at timestamptz
);
-- In the webhook handler, after verifying the signature and before responding:
insert into metalapi_events (event_id, alert_id, payload)
values ($1, $2, $3)
on conflict (event_id) do nothing;Insert the event, respond with 204, and let a worker process rows where processed_at is null. A duplicate delivery hits the primary key and changes nothing. The insert takes milliseconds, which keeps you well inside the 8-second window even when the follow-up work is slow.
Forwarding alerts to chat and other systems
A common use is posting alerts into a team chat. Slack and Discord both accept incoming webhooks, but do not point a MetalAPI alert at them directly: they expect a different payload, and you would lose signature verification. Verify on your own endpoint, then format and forward. This version of handleAlert replaces the logging stub in the Node.js server above:
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 incoming webhooks expect {"text": ...}; Discord webhooks expect {"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}`);
}For the example payload, the message reads “XAU rose above 4000 USD: now 4001.25 USD per troy_oz (Gold above 4k)”. The same pattern works for anything with an HTTP API: open a ticket, refresh cached product prices in your shop when gold moves more than 2% in a day, or notify whoever maintains a pricing feed. Run the forwarding in a worker rather than in the request handler, so a slow chat service can never make a delivery time out.
To test your endpoint without waiting for the market, sign a sample payload yourself with your secret:
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"A 204 means the signature check passed. For an end-to-end test, create an alert whose condition is already true, as described above, and watch the event appear in Recent deliveries. Alerts with webhook delivery start on the Advanced plan; see pricing for the plans, or create an account to try email alerts on Lite first.
Try it with your own data
Create a free account and make your first request in under two minutes. 100 requests a month, no credit card.
Keep reading
Gold Price API: The Complete Developer Guide
How to use a gold price API: get a key, call it from curl, JavaScript and Python, read the rates correctly, and handle history, errors, quotas and caching.
How to Add a Live Gold Price Widget to Your Website
Add a live gold price widget to WordPress, Shopify or plain HTML: build it in the MetalAPI dashboard, choose symbols, layout and theme, then paste two lines.
Gold Karat Prices Explained: 24K, 22K, 18K and How to Calculate Them
What 24K, 22K and 18K mean, how fineness marks like 916 and 750 relate, how to calculate karat gold prices per gram and how to get them from a gold price API.