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%
All articles
WebhooksAlertsTutorial

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.

By MetalAPI Team··9 min read

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.

PlanAlertsDelivery
FreeNone—
Lite3Email
Standard10Email
Advanced25Email or webhook
Pro50Email or webhook
Ultra100Email or webhook
Business200Email or webhook
Enterprise500Email 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:

ConditionFires whenThreshold
aboveprice ≥ thresholdA price in the alert's currency
belowprice ≤ thresholdA price in the alert's currency
change_pct_upchange since the previous close ≥ thresholdA percentage below 100
change_pct_downchange since the previous close ≤ −thresholdA 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:

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"
}
FieldMeaning
eventCurrently always alert.triggered. Ignore unknown values so that new event types never break your handler.
idNumeric event ID, unique per fired alert and identical across retries
createdUnix time at which the alert fired
alertThe alert as configured: id (a UUID), symbol, base, condition, threshold and note
priceThe price that triggered the alert, in base per unit
unittroy_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:

  1. 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.
  2. Check the header timestamp. Use t from the header, not created from the body. Each retry is signed again at send time with a new t, while created keeps the original time. A replay window based on created would reject valid retries.
  3. 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
// 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
# 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 "", 204

Other 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:

AttemptWhen
1As soon as the alert fires
2About 1 minute after the alert fired
3About 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:

PostgreSQL
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:

Forward to a chat 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 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:

Send a signed test event
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