署名と検証

X-ListingsAPI-Signatureヘッダーを検証し、エンドポイントの検証チャレンジに応答し、base64とhexの取り違えを防ぎます。

WebhookのURLは公開HTTPSエンドポイントなので、インターネット上の誰でもそこにPOSTを送信できます。署名シークレットを使うと、正規の配信と偽造された配信を区別できます。署名付きの各リクエストには、あなたとプラットフォームだけが保持するシークレットで計算されたHMACが含まれています。

このシークレットは、混同しやすい2つの別々の仕組みで使われます。

目的方向エンコーディング
リクエスト署名各配信が正規のものであることを証明しますヘッダーに含めて、あなた宛てに送信されますbase64
検証チャレンジシークレットを保持していることを1度だけ証明しますボディに含めて、あなたが返送しますhex

署名ヘッダー

署名付きの各配信には、次の情報が含まれます。

Text
X-ListingsAPI-Signature: sha256=<digest>   digest = base64( HMAC-SHA256(secret, raw_request_body) )

sha256=プレフィックスはヘッダー値の一部です。期待される文字列を組み立てる際にこのプレフィックスを含めるか、=より後の部分だけを比較してください。ハッシュ化する前にボディの先頭に付加するものはありません。タイムスタンプ、バージョン、連結した署名用文字列のいずれも付加しません。HMACの計算対象は、生のボディだけです。

検証の手順

  1. リクエストボディの生のバイト列を、受信したとおりに読み取ってください。 JSONを解析して再シリアライズしないでください。キーの順序、空白、Unicodeのエスケープ処理はいずれもバイト列を変化させ、どのような変更でもHMACが一致しなくなります。ほとんどのフレームワークでは、生のボディを保持するように設定する必要があります。以下のサンプルを参照してください。
  2. HMAC-SHA256(your_secret, raw_body)を計算し、base64でエンコードして、先頭にsha256=を付けてください。
  3. 定時間比較(crypto.timingSafeEqual、hmac.compare_digest)を使ってヘッダーと比較してください。単純な==では、先頭から何バイトが一致したかに関する情報が処理時間から漏れる可能性があります。
  4. 一致しない場合は401を返し、何も処理しないでください。
受信側のチェックです。重要なのは手順2です。HMACは、再エンコードしたデータではなく、受信したバイト列に対して計算します。

エンドポイントの検証

実際のイベント配信が始まる前に、エンドポイントはシークレットを実際に保持していることを1度だけ証明する必要があります。これにより、入力を間違えたURLや、署名を検証できない人が所有するURLに、署名付きペイロードが継続的に送信されるのを防ぎます。

ダッシュボードのWebhookページから検証を開始します。プラットフォームは署名付きチャレンジを送信し、応答を最大10秒待ちます。(この10秒はハンドシェイクにのみ適用され、実際の配信は5秒でタイムアウトします。)

ハンドシェイクです。形式の違いに注意してください。チャレンジの署名はヘッダー内のbase64形式で、あなたが返す応答はレスポンスボディ内のhex形式のダイジェストです。

チャレンジの仕様

チャレンジは、予約済みのイベント名を使う通常の署名付きリクエストです。

JSON
{  "event": "endpoint.verification",  "nonce": "9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c",  "timestamp": "2026-08-25T14:32:10Z"}

エンドポイントは、次の処理を行う必要があります。

  1. 通常の配信と同じ方法で X-ListingsAPI-Signature を検証します。
  2. event == "endpoint.verification" を識別し、通常のイベント処理に入る前に処理を分岐します。
  3. HMAC-SHA256(secret, nonce) を小文字の16進数文字列として計算します。HMAC の計算対象は nonce のみで、リクエストボディではありません。
  4. その16進数文字列をレスポンスボディとして、200 を返します。16進数文字列のみを返すことを想定していますが、引用符で囲んだ値や JSON 文字列にした値も受け付けます。

それ以外の場合(2xx 以外のステータス、誤ったダイジェスト、タイムアウト、ホストに到達できない場合)は、検証に失敗します。

検証状態がリセットされる条件

検証済みの状態は永続的ではありません。次のいずれかが発生すると、アカウントは未検証の状態に戻り、配信が一時停止します。

  • Webhook URL を変更するか、シークレットを再生成した場合です。以前の検証結果は、新しい設定には適用されません。
  • 検証に失敗した場合です。以前は動作していたエンドポイントでも、その後、名前解決ができなくなったり応答しなくなったりした場合が含まれます。そのため、正常に動作していないエンドポイントに対して検証を再実行すると配信が停止しますが、これは意図した動作です。

配信を再開するには、エンドポイントが正常に動作するようになってから再検証してください。

動作する受信処理の実装例

どちらのサンプルも、生のボディの保持、定数時間での署名検証、検証チャレンジへの応答という3つの処理をすべて行います。どちらも環境変数から1つのシークレットを読み込みます。有効なシークレットはアカウントごとに必ず1つで、新旧のシークレットが同時に有効になる期間はないため、2つのシークレットのいずれかを受け付けるループを実装しないでください。

Node.js (Express)
const crypto = require('crypto');const express = require('express'); const app = express();const SECRET = process.env.LISTINGSAPI_WEBHOOK_SECRET; // Keep the raw bytes. express.json() would otherwise leave you// with only the parsed object, and re-serializing it changes the// bytes the HMAC covers.app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }),); function signatureIsValid(rawBody, header) {const digest = crypto  .createHmac('sha256', SECRET)  .update(rawBody)  .digest('base64');const expected = 'sha256=' + digest; const a = Buffer.from(header || '', 'utf8');const b = Buffer.from(expected, 'utf8');// timingSafeEqual throws on a length mismatch, so check first.return a.length === b.length && crypto.timingSafeEqual(a, b);} // The only work allowed before the 200: put the raw bytes somewhere durable// and wait for the write to be acknowledged. Swap the INSERT for your real// queue or table write; it must be committed before you respond, because// nothing is retried if your process dies after the 200 goes out.async function storeForProcessing(rawBody, signature) {await db.query(  'INSERT INTO webhook_inbox (signature, body, received_at) VALUES ($1, $2, now())',  [signature, rawBody.toString('utf8')],);} app.post('/webhooks/listingsapi', async (req, res) => {const header = req.get('X-ListingsAPI-Signature');if (!signatureIsValid(req.rawBody, header)) {  return res.status(401).send('bad signature');} const body = req.body; // The verification handshake: answer with HEX, not base64.if (body.event === 'endpoint.verification') {  const answer = crypto    .createHmac('sha256', SECRET)    .update(body.nonce)    .digest('hex');  return res.status(200).send(answer);} // A real event: durable write first, acknowledge second.try {  await storeForProcessing(req.rawBody, header);} catch (err) {  // Nothing was persisted, so do not claim success.  return res.status(500).send('could not persist');}return res.status(200).json({ received: true });});
Python (Flask)
import base64import hashlibimport hmacimport os from flask import Flask, abort, request app = Flask(__name__)SECRET = os.environ["LISTINGSAPI_WEBHOOK_SECRET"].encode() HEADER = "X-ListingsAPI-Signature"  def signature_is_valid(raw_body: bytes, header: str | None) -> bool:  digest = hmac.new(SECRET, raw_body, hashlib.sha256).digest()  expected = "sha256=" + base64.b64encode(digest).decode()  return hmac.compare_digest(header or "", expected)  def store_for_processing(raw_body: bytes, signature: str) -> None:  """The only work allowed before the 200: put the raw bytes somewhere  durable and return only once the write is committed. Swap the INSERT for  your real queue or table write - nothing is retried if the process dies  after the response goes out."""  with db.begin() as tx:      tx.execute(          "INSERT INTO webhook_inbox (signature, body) VALUES (%s, %s)",          (signature, raw_body),      )  @app.post("/webhooks/listingsapi")def webhooks():  raw = request.get_data()  # raw bytes, exactly as received  header = request.headers.get(HEADER)  if not signature_is_valid(raw, header):      abort(401)   body = request.get_json(silent=True) or {}   # The verification handshake: answer with HEX, not base64.  if body.get("event") == "endpoint.verification":      nonce = body["nonce"].encode()      return hmac.new(SECRET, nonce, hashlib.sha256).hexdigest(), 200   # A real event: durable write first, acknowledge second.  try:      store_for_processing(raw, header or "")  except Exception:      # Nothing was persisted, so do not claim success.      return {"error": "could not persist"}, 500  return {"received": True}, 200

テストベクトル

固定の入力と、正しい実装が生成する正確な出力を示します。実際の配信をエンドポイントに送る前に、これらを単体テストに組み込んでください。

シークレット(明らかにダミーの値で、テスト専用です):

Text
whsec_test_0123456789abcdef0123456789abcdef

生のリクエストボディ。このバイト列と完全に一致させてください。

Text
{"event":"connection.location_connected","timestamp":"2026-08-25T14:32:10Z","account_id":11073,"location_id":"279381","data":{"platform":"google","connected_account_id":"ba03bc4b-9f8c-4d3b-8e93-9628034c63cc"}}

期待されるリクエスト署名。base64 形式で、先頭に sha256= が付きます。

Text
X-ListingsAPI-Signature: sha256=+FxQAwiCAfQdicZcHlv9fNOrkjf0yK99Jje97VMS6Y4=

検証チャレンジで、同じシークレットと次の nonce を使う場合:

Text
9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c

エンドポイントは、次の小文字の16進数文字列と完全に一致するボディで 200 を返す必要があります。

Text
d5aff73f9739b49aaabccf264c8777a697998ba07c493fbaec646b5370354145

両方を再現するには、上記のボディの行を末尾に改行を付けずに body.json に保存し(printf '%s' '...' > body.json)、次を実行します。

Python
import base64, hashlib, hmac secret = b"whsec_test_0123456789abcdef0123456789abcdef"body = open("body.json", "rb").read()          # the 209 bytes abovenonce = b"9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c" # request signature: base64, over the raw bodyprint("sha256=" + base64.b64encode(hmac.new(secret, body, hashlib.sha256).digest()).decode())# challenge answer: lowercase hex, over the nonce aloneprint(hmac.new(secret, nonce, hashlib.sha256).hexdigest())

1行目が上記のヘッダー値と一致しない場合、HMAC ではなく、バイト列が誤っています。まずボディの長さを確認してください。

次へ

  • 配信動作: 標準ペイロード、5秒の制限時間、すべての配信ステータスについて説明します。
  • イベントリファレンス: 全18種類のイベントとそのペイロードについて説明します。