Webhooks & Price Alerts
Receive signed HTTP callbacks when a price crosses your threshold, and verify them with HMAC-SHA256.
How alerts and webhooks work
MetalAPI sends webhooks when a price alert fires. An alert watches one symbol in a currency of your choice. It triggers when the price rises above or falls below a threshold, or moves up or down by a percentage since the previous close.
Alerts fire on the crossing: you are notified once when the condition becomes true, and the alert re-arms after the condition has been false again. A cooldown (60 minutes by default, configurable from 1 minute to 7 days) prevents a stream of notifications when a price hovers around the threshold.
Alerts notify you by email on the Lite plan and above, or by webhook (an HTTPS POST to your server) on the Advanced plan and above.
Setting up a webhook
- Open Alerts in the dashboard and create an alert.
- Pick the symbol, the currency, the condition and the threshold.
- Choose webhook delivery and enter your endpoint URL.
- Copy your webhook signing secret (it starts with
whsec_) from the dashboard into your server’s configuration.
Webhook URLs must use https:// on a public host. URLs that contain credentials or resolve to private or internal IP addresses are rejected, and redirects are not followed.
The payload
Each delivery is a JSON POST with these headers and body:
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=3b9f0c4e…a71d
{
"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"
}condition is one of above, below, change_pct_up or change_pct_down. price is the price that triggered the alert, in base per unit. id is unique per event, so you can use it to ignore duplicate deliveries.
Verifying the signature
Always check that a request really comes from MetalAPI before acting on it. The X-MetalAPI-Signature header has the form t=<unix timestamp>,v1=<signature>. The signature is the hex-encoded HMAC-SHA256 of the string <timestamp>.<raw request body>, keyed with your signing secret.
- Compute the HMAC over the raw body, before any JSON parsing.
- Compare signatures in constant time.
- Reject timestamps older than five minutes to block replayed requests.
In Node.js with Express:
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.METALAPI_WEBHOOK_SECRET;
app.post("/webhooks/metalapi", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-MetalAPI-Signature") ?? "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const body = req.body.toString("utf8");
const expected = crypto.createHmac("sha256", SECRET).update(`${parts.t}.${body}`).digest("hex");
const valid =
typeof parts.v1 === "string" &&
parts.v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
if (!valid || !fresh) return res.status(400).send("invalid signature");
const event = JSON.parse(body);
console.log(event.alert.symbol, event.alert.condition, event.alert.threshold, event.price);
res.sendStatus(200);
});Verifying in PHP
The same check in plain PHP:
<?php
$secret = getenv('METALAPI_WEBHOOK_SECRET');
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_METALAPI_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $parts);
$timestamp = (int) ($parts['t'] ?? 0);
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
if (!hash_equals($expected, $parts['v1'] ?? '') || abs(time() - $timestamp) > 300) {
http_response_code(400);
exit('invalid signature');
}
$event = json_decode($payload, true);
// Handle $event['alert'] and $event['price'] here.
http_response_code(200);Responding and retries
Return any 2xx status within 8 seconds. Do slow work after responding, for example in a queue, because timeouts and non-2xx responses count as failed deliveries.
Failed deliveries are retried with growing delays, up to three attempts in total within the first hour after the alert fired. A retry can arrive after a delivery that timed out on our side but succeeded on yours, so store the event id and make your handler idempotent.
Related guides
Ready to start building?
Create a free account to get your API key, or explore every endpoint in the API reference.