アラートと制限
一つのアラートは、金・銀・銅のいずれか一つの現物価格を一つのルールで監視し、一つのURLに送信します。PlusとProは、それぞれ5件のアラートを利用できます。FreeとBasicには価格アラートは含まれません。プランが終了した場合や支払いに失敗した場合は、プランが再び有効になるまで送信が停止します。
- 水準の通過: 価格が指定水準の片側から反対側へ移動します。上抜け、下抜け、または両方向を指定できます。発動後は少なくとも10分、通知を停止します。その間に価格が水準を逆方向に通過してから再び戻っても、二度目の通過は送信されません。その時間が経過し、価格が水準を逆方向に越えて通過前の側に戻り、水準から0.1%離れた場合にのみ、再発動できる状態になります。本当の再通過は通知し、水準付近の小さな揺れは除外します。
- 変動率: 1時間または24時間以内に価格が指定割合で変動します。一度発動した後、1時間が経過し、変動率がしきい値を下回ると、再発動できる状態になります。週の取引開始直後の24時間の変動は、取引再開時の価格を基準に測定します。週末の価格差は変動に含めません。
- 日次終値: UTCの一日の最後の価格を、UTCの午前零時の直後に送信します。週末など、その日に価格がない金属には終値がありません。
日付の区切りはUTCです。日次終値のアラートは毎日一度送信します。価格が水準付近で上下しても、価格更新のたびにリクエストを送信することはありません。
受信できるイベント
price.level_cross: 価格が指定水準を通過しました。price.percent_move: 価格が指定割合で変動しました。price.daily_close: UTCの一日が終了しました。webhook.test: アラートのSend testを押しました。dataは{"alert_id":"<alert id>"}です。テストは各アラートにつき毎分一度送信できます。同時に配信待ちにできるテストは最大20件です。
リクエストを読み取る
指定したURLでPOSTを受信します。URLにはhttps://、ポート443、公開ホスト名を使用してください。ユーザー名やパスワードを含めることはできず、長さは2048文字以内です。アドレスは配信のたびに確認されます。リダイレクトは追跡せず、3xx応答は失敗として扱います。応答から読み取るのはステータスコードだけです。
本文はJSON形式で、最上位にid、type、created_at、dataの四つのフィールドがあります。実際の本文では、キーの順序や空白がここの例と異なる場合があります。
- idstring
- 一意のイベント識別子です。重複をスキップするために使います。
- typestring
- 上記のイベント種別のいずれかです。
- created_atstring
Zで終わるISO 8601形式のUTC時刻です。日次終値の場合は、終了した日の23:59:59です。- data.alert_idstring
- 発動したアラートです。シークレットをローテーションしても変わりません。
- data.kindstring
level_cross、percent_move、daily_closeのいずれかです。- data.symbolstring
XAU-USD-SPOT、XAG-USD-SPOT、HG-USD-SPOTのいずれかです。- data.directionstring
- アラートの設定に応じて、
above、below、eitherのいずれかです。 - data.pricestring
- アラートを発動させた価格を表す十進数の文字列です。水準と変動率のイベントのみです。
- data.observed_atstring
- 価格が観測された時刻です。水準と変動率のイベントのみです。
- data.levelstring
- 設定した水準です。水準のイベントのみです。
- data.percent, data.window, data.reference_pricestring
- 設定した変動率、その期間(
1hまたは24h)、期間開始時の価格です。変動率のイベントのみです。 - data.date, data.closestring
- UTCの日付と、その日の最後の価格です。日次終値のイベントのみです。
リクエスト本文の例
price.level_cross
{
"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"
}
}price.percent_move
{
"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"
}
}price.daily_close
{
"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"
}
}webhook.test
{
"id": "evt_test_1792143012345",
"type": "webhook.test",
"created_at": "2026-10-23T09:30:12.345000Z",
"data": {
"alert_id": "3f0c6a52-6a2e-4b8e-9c1d-2f4b7e9a1c03"
}
}署名を検証する
イベントを処理する前にWebhook-Signatureを確認してください。受信した本文のバイト列を、そのまま使います。検証前にJSONを解析して再シリアライズしないでください。
Webhook-Signatureをカンマで分割し、tとv1を読み取ります。tが自分の時計より300秒を超えて進んでいる、または遅れている場合は、リクエストを拒否します。t、ピリオド、未加工の本文を連結してメッセージを作ります。<t>.の後に本文を続けます。- アラートの署名シークレット(
whsec_で始まります)を使ってメッセージのHMAC-SHA256を計算し、十六進数にエンコードします。 - 定数時間比較で
v1と比較します。一致しない場合はリクエストを拒否します。
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)応答と再試行の処理
10秒以内に任意の2xxを返してください。3xx、4xx、5xx、タイムアウト、接続エラーは失敗として扱います。イベントを保存してすぐに応答し、時間のかかる処理はバックグラウンドで実行してください。
- 初回の再試行: 最初の失敗から1分後。
- 二回目の再試行: 前回の失敗から5分後。
- 三回目の再試行: 前回の失敗から30分後。
- 四回目の再試行: 前回の失敗から2時間後。
- 五回目の再試行(最終): 前回の失敗から6時間後。
合計で約8.5時間にわたり6回試行します。最後の試行に失敗すると、そのイベントは再送されません。メールによる代替通知もありません。各試行は新しいタイムスタンプで再署名されます。初回の試行時に作成から一時間を超えているイベントは破棄されます。配信記録は30日保存します。
署名シークレットをローテーションする
シークレットはアラートの追加時に一度だけ表示されます。漏えいの可能性がある場合や紛失した場合は、ダッシュボードのアラートでRotate secretを選択してください。新しいシークレットも一度だけ表示されます。アラートの識別子は変わらず、古いシークレットはすぐに無効になります。以後の配信には新しいシークレットが使われます。配信の失敗を防ぐため、切り替え中の数分間は、サーバーで両方のシークレットを受け付けるようにしてください。
よくある質問
価格アラートにはどのプランが必要ですか?
Plus ($39/月) とPro ($89/月) には、価格アラートがそれぞれ5件含まれています。FreeプランとBasicプランには、価格アラートは含まれていません。
一つのアラートはどのくらいの頻度で発動しますか?
水準アラートは発動後、少なくとも10分間は通知せず、その間の通過は送信されません。その時間が経過し、価格が水準を逆方向に越えて通過前の側に戻り、水準から0.1%離れた場合にのみ、再発動できる状態になります。変動率アラートは一度発動し、1時間後、変動が解消すると再発動可能になります。日次終値はUTCの各日に一度送信します。
サーバーが停止している場合はどうなりますか?
配信に失敗すると、約8.5時間にわたり最大でさらに5回再試行します。最後の試行に失敗すると、そのイベントは再送されません。メールによる代替通知もありません。
同じイベントを二回受信することはありますか?
はい。すでに処理済みでも、配信が再試行されることがあります。たとえば、応答が10秒を過ぎてから届いた場合です。処理済みのイベント識別子を保存し、重複をスキップしてください。
関連情報
- /docs/stream : WebSocketとSSEによるリアルタイム価格
- /pricing : プランと制限