Menu

Price alert webhooks

Get a signed POST request at your URL when gold, silver or copper crosses a level, moves by a percentage or closes for the day. Add an alert in your dashboard, verify each request, then act on it.

Plus · $39/moPro · $89/mo

Alerts and limits

One alert watches one metal (gold, silver or copper spot) with one rule and sends to one URL. Plus and Pro each include 5 alerts. Free and Basic do not include price alerts. If your plan ends or your payment fails, sending stops until the plan is active again.

  • Level cross: the price moves from one side of a level to the other, above, below or either way.
  • Percent move: the price moves by a percentage within 1 hour or 24 hours. It fires once, then again only after the move has fallen back under the threshold.
  • Daily close: the last price of the UTC day, sent just after midnight UTC. A metal with no price that day, such as a weekend, has no close.

Days are UTC days. Each alert sends at most once an hour for level and percent rules and once a day for the daily close, so a price wobbling around a level sends one request, not one per tick.

Events you can receive

  • price.level_cross: the price crossed your level.
  • price.percent_move: the price moved by your percentage.
  • price.daily_close: the UTC day ended.
  • webhook.test: you pressed Send test on an alert. Its data is {"alert_id":"<alert id>"}. You can send one test per alert per minute, and no more than 20 can wait for delivery at once.

Read the request

You receive a POST at your URL. Your URL must use https://, port 443 and a public hostname, with no user name or password and no more than 2048 characters. The address is checked before every delivery. Redirects are not followed, and a 3xx reply counts as a failure. Only your status code is read.

Content-Type: application/jsonUser-Agent: webhook-delivery/1Webhook-Signature: t=<unix seconds>,v1=<hex>

The body is JSON with four top-level fields: id, type, created_at and data. Key order and spacing in the real body can differ from the examples here.

idstring
A unique event id. Use it to skip repeats.
typestring
One of the event types above.
created_atstring
An ISO 8601 UTC time ending in Z. For a daily close it is 23:59:59 of the closed day.
data.alert_idstring
The alert that fired. It does not change when you rotate the secret.
data.kindstring
level_cross, percent_move or daily_close.
data.symbolstring
XAU-USD-SPOT, XAG-USD-SPOT or HG-USD-SPOT.
data.directionstring
above, below or either, as set on the alert.
data.pricestring
The price that triggered the alert, as a decimal string. Level and percent events only.
data.observed_atstring
When the price was seen. Level and percent events only.
data.levelstring
The level you set. Level events only.
data.percent, data.window, data.reference_pricestring
The percentage you set, its period (1h or 24h) and the price at the start of the period. Percent events only.
data.date, data.closestring
The UTC day and its last price. Daily close events only.

Example bodies

price.level_cross

price.percent_move

price.daily_close

webhook.test

{
  "id": "evt_3f0c6a52-6a2e-4b8e-9c1d-2f4b7e9a1c03_level_cross:492841",
  "type": "price.level_cross",
  "created_at": "2026-10-23T14:02:11.482113Z",
  "data": {
    "alert_id": "3f0c6a52-6a2e-4b8e-9c1d-2f4b7e9a1c03",
    "kind": "level_cross",
    "symbol": "XAU-USD-SPOT",
    "direction": "above",
    "price": "4203.1",
    "level": "4200",
    "observed_at": "2026-10-23T14:02:11.482113+00:00"
  }
}
{
  "id": "evt_8a1d52c4-0b7e-4f55-a3d9-61e2c7b8f410_percent_move:492855",
  "type": "price.percent_move",
  "created_at": "2026-10-23T09:15:40.120934Z",
  "data": {
    "alert_id": "8a1d52c4-0b7e-4f55-a3d9-61e2c7b8f410",
    "kind": "percent_move",
    "symbol": "XAU-USD-SPOT",
    "direction": "either",
    "price": "4168.8",
    "percent": "2",
    "window": "24h",
    "reference_price": "4083.05",
    "observed_at": "2026-10-23T09:15:40.120934+00:00"
  }
}
{
  "id": "evt_c92e0f7b-31a4-4d6a-8e57-a05b3d1c92e8_close:2026-10-22",
  "type": "price.daily_close",
  "created_at": "2026-10-22T23:59:59Z",
  "data": {
    "alert_id": "c92e0f7b-31a4-4d6a-8e57-a05b3d1c92e8",
    "kind": "daily_close",
    "symbol": "XAG-USD-SPOT",
    "direction": "either",
    "date": "2026-10-22",
    "close": "48.62"
  }
}
{
  "id": "evt_test_1792143012345",
  "type": "webhook.test",
  "created_at": "2026-10-23T09:30:12.345000Z",
  "data": {
    "alert_id": "3f0c6a52-6a2e-4b8e-9c1d-2f4b7e9a1c03"
  }
}

Verify signatures

Check Webhook-Signature before you act on an event. Use the raw body bytes exactly as received. Never parse and re-serialize the JSON before checking.

  1. Split Webhook-Signature on commas to read t and v1.
  2. Reject the request if t is more than 300 seconds ahead of or behind your clock.
  3. Build the message from t, a full stop and the raw body: <t>. followed by the body.
  4. Compute HMAC-SHA256 of the message with your alert's signing secret (it starts with whsec_) and encode it as hexadecimal.
  5. Compare it with v1 using a constant-time comparison. Reject the request if they differ.

Node.js

Python

import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const TOLERANCE_SECONDS = 300;

// rawBody: the request body exactly as received (a Buffer), before any JSON parsing.
function verifyWebhook(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
  const fields = {};
  for (const part of String(header ?? "").split(",")) {
    const i = part.indexOf("=");
    if (i > 0) fields[part.slice(0, i).trim()] = part.slice(i + 1).trim();
  }
  const t = Number(fields.t);
  if (!Number.isInteger(t) || Math.abs(now - t) > TOLERANCE_SECONDS) return false;
  const expected = createHmac("sha256", secret).update(t + ".").update(rawBody).digest();
  const given = Buffer.from(fields.v1 ?? "", "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}

const seen = new Set(); // use your database in production

createServer((req, res) => {
  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", () => {
    const rawBody = Buffer.concat(chunks);
    const header = req.headers["webhook-signature"];
    if (!verifyWebhook(rawBody, header, process.env.WEBHOOK_SECRET)) {
      res.writeHead(400).end();
      return;
    }
    const event = JSON.parse(rawBody);
    if (!seen.has(event.id)) {
      seen.add(event.id);
      // Hand the event to a queue or background job here, then answer quickly.
      console.log(event.type, event.data);
    }
    res.writeHead(204).end();
  });
}).listen(Number(process.env.PORT ?? 3000));
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify_webhook(raw_body: bytes, header: str, secret: str, now: int | None = None) -> bool:
    """raw_body is the request body exactly as received, before any JSON parsing."""
    fields = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
    try:
        t = int(fields["t"])
    except (KeyError, ValueError):
        return False
    now = int(time.time()) if now is None else now
    if abs(now - t) > TOLERANCE_SECONDS:
        return False
    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(fields.get("v1", "").encode(), expected.encode())


# Flask:  verify_webhook(request.get_data(), request.headers.get("Webhook-Signature", ""), secret)
# Django: verify_webhook(request.body, request.headers.get("Webhook-Signature", ""), secret)

Respond and handle retries

Return any 2xx within 10 seconds. A 3xx, 4xx, 5xx, timeout or connection error counts as a failed attempt. Store the event and return quickly, and do slow work in the background.

  • First retry: 1 minute after the first failure.
  • Second retry: 5 minutes after the previous failure.
  • Third retry: 30 minutes after the previous failure.
  • Fourth retry: 2 hours after the previous failure.
  • Fifth and final retry: 6 hours after the previous failure.

That is 6 attempts over about 8.5 hours. After the last failed attempt the event is not sent again, and there is no email fallback. Each attempt is signed again with a fresh timestamp. An event that is more than an hour old when it is first tried is dropped.

Rotate the signing secret

The secret is shown once, when you add the alert. If it may have leaked or you lost it, choose Rotate secret on the alert in your dashboard. The new secret is shown once. The alert keeps its id, the old secret stops working at once, and deliveries from then on use the new one. To avoid failed deliveries, let your server accept both secrets for a few minutes while you switch.

Questions

Which plan do I need for price alerts?

Plus ($39/mo) and Pro ($89/mo) each include 5 alerts. Free and Basic do not include price alerts.

How often can one alert fire?

Level and percentage alerts send at most once an hour for each alert. A daily close alert sends once a day. Days are UTC days.

What happens if my server is down?

A failed delivery is tried again up to 5 more times over about 8.5 hours. After the last failed attempt the event is not sent again, and there is no email fallback.

Can I get the same event twice?

Yes. A delivery can be retried even if you already handled it, for example when your reply arrives after 10 seconds. Store the event ids you have handled and skip repeats.

See also