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%
すべての記事
Webhookアラートチュートリアル

Webhookで金・銀の価格アラートを自動化する

署名付きWebhookで金・銀の価格アラートを自動化する方法。アラート条件、クールダウン、ペイロード、Node.jsとPythonでのHMAC-SHA256検証、リトライまで解説します。

執筆:MetalAPI Team··9分で読めます

ポーリングの代わりにアラートを

多くの連携では、価格が意味を持つのは特定の瞬間だけです。金が顧客の指定した水準を超えたとき、銀が急落したとき、あるいはカタログの価格を改定するほど通貨が動いたときです。そうした瞬間を捉えるために/latestを1分ごとにポーリングすると、月に約45,000リクエストを消費するうえ、水準の突破を検知するロジックも自分で書かなければなりません。

MetalAPIの価格アラートは、その処理をサーバー側に移します。アラートエンジンは、届いたリアルタイム価格を有効なすべてのアラートと照合し、条件が満たされると通知します。アラートのチェックはAPIクォータを消費せず、あなたのコードが動くのは何かが起きたときだけです。

プランアラート数通知方法
Freeなし—
Lite3メール
Standard10メール
Advanced25メールまたはWebhook
Pro50メールまたはWebhook
Ultra100メールまたはWebhook
Business200メールまたはWebhook
Enterprise500メールまたはWebhook

アラートはダッシュボードのAlertsで管理します。Webhookガイドは簡潔なリファレンスで、この記事では本番環境で動かすエンドポイントに必要な詳細を説明します。しきい値のイベントではなく、すべての価格更新が必要な場合は、リアルタイムストリーミングの記事で別のアプローチを紹介しています。

アラート条件と発火のタイミング

各アラートは、1つの通貨建てで1つのシンボルを監視します。たとえばUSD建てのXAUや、EUR建てのXAGです。プラチナ、パラジウム、産業用金属、エネルギー、通貨、暗号資産を含め、有効なシンボルであればどれでも使えます。条件は4種類です:

条件発火するタイミングしきい値
above価格 ≥ しきい値アラートの通貨建ての価格
below価格 ≤ しきい値アラートの通貨建ての価格
change_pct_up前日終値からの変動率 ≥ しきい値100未満のパーセント値
change_pct_down前日終値からの変動率 ≤ −しきい値100未満のパーセント値(正の数で入力)

金属のしきい値は、基準単位であるトロイオンスあたりの価格です。これは、ほかではポンドあたりで表示されることが多い銅などの産業用金属にも当てはまります。エネルギーは契約単位(原油ならバレル)を使い、Webhookのペイロードには必ず単位が示されます。

価格と変動率は、アラートの通貨建てで計算されます。EUR建てのXAGの場合、米ドル建てでは銀の価格が変わっていなくても、ユーロ高だけで銀がしきい値を下回ったり、change_pct_downがトリガーされたりすることがあります。

アラートは、条件が成立している間の価格更新のたびにではなく、しきい値をまたいだときに発火します。金が$4,000を上抜けたときの通知は1回だけです。条件がいったん不成立に戻るとアラートは再び有効になるため、その後いったん水準を下回り、再び上抜ければ、また通知されます。

クールダウンは、同じアラートから届く2つの通知の最小間隔です。5分から7日(10,080分)の範囲で設定でき、デフォルトは60分です。しきい値付近で上下する価格は1時間に何度も水準をまたぐことがありますが、クールダウンによって通知は1回にまとめられます。一度だけ意味を持つ水準には、長いクールダウンを設定し、発火後にアラートを一時停止してください。

テストに便利な挙動が1つあります。新しいアラートには以前の状態がないため、条件がすでに成立していれば、次の価格更新で発火します。市場が開いていれば、通常は1分以内です。クールダウン5分で「XAUが1 USDを上回る」アラートを作れば、テスト用に実際の配信を受け取れます。テストが終わったら削除してください。

メールとWebhookによる通知

メールアラートはLiteプランから利用できます。こちらは人に向けたもので、「MetalAPI alert: Gold (XAU) rose above 4000 USD」のような件名、現在の価格、アラートに添えたメモが記載されます。

Advancedプランから利用できるWebhookは、システムに向けたものです。指定したURLにJSON本文付きのHTTPS POSTを送信し、MetalAPIから送られたことを証明できるよう署名されています。WebhookのURLは、次の条件を満たす必要があります:

  • https://を使用していること。
  • パブリックIPアドレスに解決されること(プライベート、ループバック、リンクローカルの範囲は、アラートの保存時に拒否され、配信時にも再チェックされます)。
  • ユーザー名やパスワードを含まないこと。

リダイレクトはたどらないため、最終的なURLを入力してください。アカウントがWebhookのないプランに移行した場合、Webhookアラートは破棄されず、代わりにメールで配信されます。

アカウントのすべてのWebhookは、whsec_で始まる1つのシークレットで署名されます。シークレットの作成、表示、コピー、ローテーションは、AlertsページのWebhook signing secretパネルで行えます。まだ作成していない場合は、最初のWebhook配信時に生成され、その後パネルに表示されます。ローテーションは即座に反映されます。この点は、後述の検証コードにとって重要です。

Webhookリクエスト

配信はすべて次のような形式です:

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"
}
フィールド意味
event現時点では常にalert.triggeredです。新しいイベントタイプが追加されてもハンドラーが壊れないよう、不明な値は無視してください。
id数値のイベントID。発火したアラートごとに一意で、リトライ間で同一
createdアラートが発火したUnix時刻
alert設定されたアラート:id(UUID)、symbol、base、condition、threshold、note
priceアラートをトリガーした価格。unitあたりのbase建て
unit金属はtroy_oz、エネルギーはbbl、mmbtu、gal、通貨はcurrency、暗号資産はcoin

最大200文字のnoteフィールドは、チーム名、チャンネル、商品のSKUなど、ダッシュボードで設定するルーティング情報を入れておくのに便利です。

Node.jsで署名を検証する

X-MetalAPI-Signatureヘッダーはt=<timestamp>,v1=<signature>という形式です。署名は、文字列<timestamp>.<raw body>をwhsec_シークレットをキーとしてHMAC-SHA256で計算し、小文字の16進数で表したものです。タイムスタンプはX-MetalAPI-Timestampでも単独で送信されます。本番環境でチェックが正しく機能するかどうかは、次の3点で決まります:

  1. 生のバイト列をハッシュする。 JSONをパースして再びシリアライズすると、空白や数値の書式が変わり、署名が一致しなくなることがあります。本文はバッファとして読み込んでください。
  2. ヘッダーのタイムスタンプを確認する。 本文のcreatedではなく、ヘッダーのtを使ってください。リトライのたびに、送信時点の新しいtで署名し直されますが、createdは元の時刻のままです。createdに基づいてリプレイ対策の時間枠を設けると、正当なリトライを拒否してしまいます。
  3. ローテーション中は2つのシークレットを受け入れる。 ダッシュボードでローテーションすると、署名は即座に新しいシークレットに切り替わります。古いシークレットと並べて新しいシークレットを追加し、デプロイしてください。その間に失敗した配信は数分間リトライされます。新しいシークレットで動作することを確認したら、古いシークレットを削除します。

Expressでは、express.raw()が本文をBufferとして渡してくれます:

webhook-server.mjs
// webhook-server.mjs — Node.js 18以降、npm install express
import crypto from "node:crypto";
import express from "express";

// カンマ区切り:現在のシークレットと、ローテーション中は以前のシークレット。
const SECRETS = (process.env.METALAPI_WEBHOOK_SECRETS ?? "").split(",").filter(Boolean);
const TOLERANCE_S = 300; // 5分より古い署名は拒否する

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); // 8秒以内に応答してから処理を行う
  if (event.event === "alert.triggered") {
    handleAlert(event).catch((err) => console.error("alert handler failed:", err));
  }
});

app.listen(3000);

このハンドラーは、処理を行う前に応答を返します。処理が重要な場合は、応答後にクラッシュしてもイベントが失われないよう、まずイベントを永続的に保存してください(方法は以降のセクションで説明します)。

Pythonで署名を検証する

Pythonでの同じチェックは、標準ライブラリだけで実装できます。定数時間の比較にはhmac.compare_digestを使います:

webhook_app.py
# webhook_app.py — pip install flask の後、flask --app webhook_app run --port 3000 で起動
import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

# カンマ区切り:現在のシークレットと、ローテーション中は以前のシークレット。
SECRETS = [s for s in os.environ.get("METALAPI_WEBHOOK_SECRETS", "").split(",") if s]
TOLERANCE_S = 300  # 5分より古い署名は拒否する

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()  # JSONをパースする前の生のバイト列
    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"]
        # ここでイベントをジョブキューに渡し、すぐに応答を返す。
        app.logger.warning("%s/%s %s %s: %s", alert["symbol"], alert["base"],
                           alert["condition"], alert["threshold"], event["price"])
    return "", 204

ほかのフレームワークで異なるのは、生の本文を読む方法だけです。FastAPIではawait request.body()、Djangoではrequest.bodyを使います。どのフレームワークでも、ハンドラーが実行される前にミドルウェアが本文をパースして消費していないことを確認してください。

リトライ、重複、応答時間

エンドポイントが8秒以内に任意の2xxステータスで応答すれば、配信は成功です。タイムアウト、接続エラー、リダイレクト、その他すべてのステータスは失敗とみなされ、リトライされます:

試行タイミング
1アラートの発火直後
2アラートの発火から約1分後
3アラートの発火から約4分後

3回試行すると、イベントは失敗としてマークされます。AlertsページのRecent deliveriesテーブルには、すべてのイベントとその結果が、最後の試行のHTTPステータスやエラーメッセージとともに一覧表示されます。エンドポイントの動作がおかしいときは、まずここを確認してください。

リトライでは同じイベントidが使われます。サーバーで処理したものの応答に8秒以上かかった配信は、MetalAPI側では失敗とみなされて再び届くため、ハンドラーは冪等でなければなりません。最も簡単なのは、イベントIDをキーとするテーブルを使う方法です:

PostgreSQL
create table metalapi_events (
  event_id     bigint primary key,      -- ペイロードの "id"、リトライ間で同一
  alert_id     uuid not null,
  payload      jsonb not null,
  received_at  timestamptz not null default now(),
  processed_at timestamptz
);

-- Webhookハンドラー内で、署名の検証後、応答を返す前に実行:
insert into metalapi_events (event_id, alert_id, payload)
values ($1, $2, $3)
on conflict (event_id) do nothing;

イベントを挿入して204で応答し、processed_atがnullの行はワーカーに処理させます。重複した配信は主キーに衝突するだけで、何も変わりません。挿入は数ミリ秒で終わるため、後続の処理が遅くても、8秒の制限時間に十分収まります。

アラートをチャットやほかのシステムに転送する

よくある用途は、アラートをチームのチャットに投稿することです。SlackもDiscordも受信Webhookに対応していますが、MetalAPIのアラートを直接そこに向けないでください。期待されるペイロードが異なるうえ、署名の検証ができなくなります。自分のエンドポイントで検証してから、整形して転送しましょう。次のhandleAlertは、上のNode.jsサーバーにあるログ出力だけの仮実装を置き換えるものです:

チャットの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の受信Webhookは {"text": ...}、DiscordのWebhookは {"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}`);
}

サンプルのペイロードの場合、メッセージは「XAU rose above 4000 USD: now 4001.25 USD per troy_oz (Gold above 4k)」となります。同じパターンは、HTTP APIを持つあらゆるものに使えます。チケットを起票する、金が1日に2%以上動いたらショップでキャッシュしている商品価格を更新する、価格フィードの担当者に通知する、といった具合です。転送はリクエストハンドラーではなくワーカーで実行してください。そうすれば、チャットサービスの応答が遅くても、配信がタイムアウトすることはありません。

相場の動きを待たずにエンドポイントをテストするには、自分のシークレットでサンプルのペイロードに署名します:

署名付きテストイベントを送信
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"

204が返れば、署名のチェックは成功です。エンドツーエンドでテストするには、前述のとおり条件がすでに成立しているアラートを作成し、Recent deliveriesにイベントが表示されるのを確認してください。Webhookで通知するアラートはAdvancedプランから利用できます。プランについては料金をご覧ください。まずLiteでメールアラートを試すなら、アカウントを作成してください。

ご自身のデータで試してみましょう

無料アカウントを作成すれば、2分以内に最初のリクエストを送信できます。月100リクエスト、クレジットカード不要。

関連記事