Webhookと価格アラート
価格が設定したしきい値を超えたときに署名付きのHTTPコールバックを受け取り、HMAC-SHA256で検証する方法を、Node.jsとPHPのコード例、リトライの仕組みとあわせて解説します。
アラートとWebhookの仕組み
MetalAPIは、価格アラートが発火したときにWebhookを送信します。アラートは、任意の通貨建てで1つのシンボルを監視します。価格がしきい値を上回ったとき・下回ったとき、または前日の終値から一定の割合だけ上昇・下落したときにトリガーされます。
アラートは条件をまたいだ時点で発火します。条件が成立したときに一度だけ通知され、条件がいったん不成立に戻るとアラートは再び有効になります。クールダウン(デフォルトは60分、5分〜7日の範囲で設定可能)により、価格がしきい値付近で上下している場合に通知が連続して届くのを防ぎます。
アラートの通知方法は、Liteプラン以上ではメール、Advancedプラン以上ではWebhook(お客様のサーバーへのHTTPS POST)です。
Webhookを設定する
- ダッシュボードでAlertsを開き、アラートを作成します。
- シンボル、通貨、条件、しきい値を選びます。
- 配信方法としてWebhookを選び、エンドポイントのURLを入力します。
- ダッシュボードからWebhookの署名シークレット(
whsec_で始まります)をコピーし、サーバーの設定に追加します。
WebhookのURLには、公開ホスト上のhttps://を使用する必要があります。認証情報を含むURLや、プライベートIPアドレス・内部IPアドレスに解決されるURLは拒否されます。また、リダイレクトはたどりません。
ペイロード
各配信は、次のヘッダーと本文を持つJSONのPOSTリクエストです:
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はabove、below、change_pct_up、change_pct_downのいずれかです。priceはアラートをトリガーした価格で、unitあたりのbase建てで表されます。idはイベントごとに一意なので、重複した配信を無視するために使用できます。
署名を検証する
リクエストを処理する前に、それが本当にMetalAPIから送られたものかを必ず確認してください。X-MetalAPI-Signatureヘッダーはt=<unix timestamp>,v1=<signature>という形式です。署名は、文字列<timestamp>.<raw request body>を署名シークレットをキーとしてHMAC-SHA256で計算し、16進数でエンコードしたものです。
- HMACは、JSONをパースする前の生の本文に対して計算してください。
- 署名は定数時間で比較してください。
- リプレイ攻撃を防ぐため、5分より古いタイムスタンプは拒否してください。
Node.jsと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);
});PHPで検証する
同じ検証をフレームワークなしの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);
// ここで $event['alert'] と $event['price'] を処理します。
http_response_code(200);応答とリトライ
8秒以内に任意の2xxステータスを返してください。タイムアウトや2xx以外のレスポンスは配信失敗として扱われるため、時間のかかる処理はキューを使うなどして、応答を返した後に行ってください。
失敗した配信は間隔を延ばしながらリトライされ、アラートの発火から1時間以内に合計で最大3回試行されます。当社側ではタイムアウトしたものの、お客様側では成功していた配信の後にリトライが届くこともあるため、イベントのidを保存し、ハンドラーを冪等にしてください。
関連ガイド
開発を始める準備はできましたか?
無料アカウントを作成してAPIキーを取得するか、APIリファレンスですべてのエンドポイントをご確認ください。