Claude CodeでShopify Webhookを受ける — HMAC検証と重複配信の冪等化
Shopify Webhookの署名検証、Webhook-Idによる重複排除、App Storeアプリが必須の3つの準拠トピックを、テストとフックでClaude Codeに書かせる手順です。
ShopifyのWebhook受信は、署名検証が通って200を返せば終わり、とは限りません。同じ配信が複数回届くことがあり、App Storeに出すアプリには、注文と無関係に受けるべき3つのトピックもあります。Claude Codeに任せると、この2点が抜けた実装になりがちです。
先に結論を書きます。重複排除のキーはX-Shopify-Event-IdではなくX-Shopify-Webhook-Idです。署名検証は未加工のボディに対して行います。準拠トピックの3つ(customers/data_request・customers/redact・shop/redact)は、設定ファイルの抜けをフックで検知します。この記事は、その実装とテストを示します。Shopify Functionsの検証はShopify Functionsの記事、公式ツールキットの使い方はShopify AI Toolkitの記事にあります。ここではWebhookの受信側に絞ります。
重複排除に使うヘッダーはどれか
Shopifyの配信ヘッダーには、似た名前のIDが2つあります。取り違えると、重複を落とせないか、別の配信まで落とします。
| ヘッダー | 意味 | 重複排除に使うか |
|---|---|---|
X-Shopify-Webhook-Id | 意味配信ごとの一意キー | 重複排除に使うか使う |
X-Shopify-Event-Id | 意味同じ操作から生まれた全配信で共通のID | 重複排除に使うか使わない(相関用) |
X-Shopify-Triggered-At | 意味配信を起こした時刻 | 重複排除に使うか順序の判断に使う |
同じトピックに複数の購読があると、購読ごとに別々の配信が届きます。各配信のWebhook-Idは異なり、Event-Idは共通です。主キーに選ぶ側で、結果が分かれます。
ヘッダー名は大文字小文字を区別しないものとして扱います。HTTP/2では小文字になることが多いためです。Node.jsのhttpモジュールは小文字で渡します。
Event-Idを主キーにした場合と、Webhook-Idの場合
Event-Idで落とす
購読が2本ある構成では、2本目の配信が処理済み扱いになります。購読ごとに別の処理を持たせている場合、片方が動きません。
Webhook-Idで落とす
再送された同一の配信だけが重複として落ちます。購読が複数あっても、それぞれ処理されます。
Shopifyは配信の重複を減らす設計ですが、ネットワークのタイムアウトや再試行で同じWebhookを2回受けることはあります。処理自体を冪等にできるなら、それが第一の対策です。冪等にできない処理にだけ、Webhook-Idの記録を足します。
Claude Codeに渡す指示とCLAUDE.mdの規約
実装の前に、守らせたい条件を規約として渡します。口頭の指示だけだと、署名検証の前にJSONを解析する実装が出ることがあります。
## Shopify Webhook受信の規約
- 署名検証は、JSON解析前の生ボディ(Buffer)に対して行う
- HMACの比較は `crypto.timingSafeEqual` を使う
- 署名が不正なら 401 を返し、何も処理しない
- 重複排除のキーは `X-Shopify-Webhook-Id`。`X-Shopify-Event-Id` は使わない
- 重複なら処理せず 200 を返す。処理に失敗したら ID を残さず 500 を返す
- 準拠トピック3つを shopify.app.toml の compliance_topics に必ず入れる
- 変更後は `node --test` を実行し、失敗したら直すShopifyの公式手順でも、HMACは未加工のリクエストボディとアプリのクライアントシークレットから計算します。ミドルウェアの順序も注意点として挙げられています。ボディを解析する処理より前に検証を置きます。
署名検証と重複排除の実装
依存を増やさずに、Node.jsの標準モジュールだけで書いた例です。公式の例(createHmacとtimingSafeEqual)と同じ構成を、テストしやすい関数に分けています。
// src/webhook.js
import crypto from "node:crypto";
export function verifyHmac(rawBody, header, secret) {
if (!header) return false;
const digest = crypto.createHmac("sha256", secret).update(rawBody).digest();
const given = Buffer.from(header, "base64");
return given.length === digest.length && crypto.timingSafeEqual(given, digest);
}
export function createHandler({ secret, seen, process }) {
return async function handle(headers, rawBody) {
if (!verifyHmac(rawBody, headers["x-shopify-hmac-sha256"], secret)) {
return { status: 401 };
}
const id = headers["x-shopify-webhook-id"];
if (!id) return { status: 400 };
if (!seen.add(id)) return { status: 200, duplicate: true };
try {
await process(headers["x-shopify-topic"], JSON.parse(rawBody));
} catch (e) {
seen.delete(id);
return { status: 500 };
}
return { status: 200 };
};
}timingSafeEqualは、長さの違うバッファを渡すと例外を投げます。そのため、比較の前に長さを確認しています。不正な署名を送られても、例外で落ちず401になります。
seenは処理済みIDの置き場です。次の例はメモリ上の簡易版で、テスト用です。本番では、再起動しても消えない永続ストアに置きます。公式も、IDの確認先は永続ストアとしています。
// src/store.js
export class SeenStore {
#ids = new Set();
add(id) { if (this.#ids.has(id)) return false; this.#ids.add(id); return true; }
delete(id) { this.#ids.delete(id); }
}データベースに置く場合は、webhook_idに一意制約を付け、挿入の成否で判定します。「存在を確認してから挿入」の2段階にすると、同時に届いた2本が両方とも通ります。Stripeで同じ設計を扱ったStripe webhookの冪等受信の記事に、受信箱テーブルの形があります。
処理の失敗時は、記録したIDを消して500を返します。IDを残したまま失敗を返すと、再送された配信が「処理済み」として捨てられ、処理が永久に抜けます。
重複・署名・IDの取り違えを再現するテスト
Claude Codeに実装を書かせたら、同時に次のテストを書かせます。取り違えやすい点をそのままケースにしています。
// test/webhook.test.js(抜粋)
import test from "node:test";
import assert from "node:assert/strict";
import crypto from "node:crypto";
import { createHandler } from "../src/webhook.js";
import { SeenStore } from "../src/store.js";
const secret = "test-secret";
const sign = (b) => crypto.createHmac("sha256", secret).update(b).digest("base64");
const body = JSON.stringify({ id: 1 });
const hdr = (o = {}) => ({
"x-shopify-hmac-sha256": sign(body),
"x-shopify-webhook-id": "w-1",
"x-shopify-event-id": "e-1",
"x-shopify-topic": "orders/create",
...o,
});
test("本文を1バイト変えると署名検証に落ちる", async () => {
const calls = [];
const handle = createHandler({
secret, seen: new SeenStore(), process: async (t) => calls.push(t),
});
assert.equal((await handle(hdr(), body + " ")).status, 401);
assert.equal(calls.length, 0);
});
test("Event-Idが同じでもWebhook-Idが違えば別配信として処理する", async () => {
const calls = [];
const handle = createHandler({
secret, seen: new SeenStore(), process: async (t) => calls.push(t),
});
await handle(hdr({ "x-shopify-webhook-id": "w-1" }), body);
await handle(hdr({ "x-shopify-webhook-id": "w-2" }), body);
assert.equal(calls.length, 2);
});手元のNode.js v20.18.0で、重複・署名不正・処理失敗後の再送・準拠トピックの受け付けを含む6ケースをnode --testで実行したところ、すべて通りました。2つ目のテストは、Webhook-IdをEvent-Idに変えた実装を即座に落とします。取り違えを防ぐ、いちばん安い網です。
node --test準拠トピックの抜けをフックで見張る
Shopify App Storeで配布するアプリは、3つの準拠トピックを購読する必要があります。Shopifyのドキュメントは、実装していない、または要求どおりに応答しないアプリは審査で差し戻されると説明しています。注文を扱わないアプリも例外ではありません。
| トピック | 届くタイミング | 求められること |
|---|---|---|
customers/data_request | 届くタイミング顧客データの閲覧要求時 | 求められること該当データをストア側に提供する |
customers/redact | 届くタイミング削除要求の10日後(ギフトカードが未配達なら配達後) | 求められること該当顧客のデータを削除する |
shop/redact | 届くタイミングアプリのアンインストールの48時間後 | 求められることそのストアのデータを消す |
どれも、受信したら200番台で応答し、30日以内に処理を終えます。法的に保持義務があって削除できない場合は、削除しません。不正な署名には401を返す必要があります。この点は通常のWebhookと同じです。
設定はshopify.app.tomlに書きます。トピックはcompliance_topicsに並べます。Admin APIでは購読できません。
[webhooks]
api_version = "2026-10"
[[webhooks.subscriptions]]
compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"]
uri = "https://app.example.com/webhooks"受信口を実際に叩いて確かめるには、Shopify CLIのwebhook triggerコマンドで配信を手動で起こせます。ただし公式の注記のとおり、手動のトリガーでは購読の設定自体は検証されません。購読の抜けは、次のスクリプトのような静的な確認で拾います。
ここが抜けても、通常のWebhookのテストは通ります。気づくのは審査の差し戻しです。そこで、設定ファイルの変更時に3トピックの有無を確かめるスクリプトを置きます。
#!/bin/bash
# .claude/hooks/check-compliance.sh (chmod +x が必要。jqを使う)
FILE=$(jq -r '.tool_input.file_path // empty')
case "$FILE" in
*/shopify.app*.toml) ;;
*) exit 0 ;;
esac
for t in customers/data_request customers/redact shop/redact; do
grep -q "\"$t\"" "$FILE" || { echo "missing compliance topic: $t" >&2; exit 2; }
donePostToolUseフックに、このスクリプトとnode --testの両方を登録します。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-compliance.sh"
},
{
"type": "command",
"command": "node --test >&2 || exit 2",
"timeout": 120
}
]
}
]
}
}PostToolUseは、ツールが成功した後に走ります。exit 2を返してもファイルの変更は取り消されません。代わりに標準エラー出力がClaudeに渡されるので、missing compliance topic: shop/redactのような一行が次の修正の入力になります。手元でトピックを2つだけ書いたshopify.app.tomlに対してスクリプトを動かすと、customers/data_requestの欠落を報告して終了コード2で止まりました。
フックの置き場所やマッチャーの考え方は、フックでテストを自動実行する記事にまとめています。
5秒、再試行、順序の崩れに備える
HTTPSで受ける場合の制約は、実装の形を決めます。
HTTPS配信の制限
接続のタイムアウト
1秒
リクエスト全体では5秒
再試行
8回
約4時間の間に
- 応答は
200番台のみが成功です。3xxも失敗として扱われます - 8回連続で失敗すると、Admin APIで作った購読は自動で削除されます
- 5秒を超える処理は、キューに積んで先に
200を返します
重い処理を同期で書いたまま重複排除を足すと、タイムアウトで再送が増え、重複が増えるという悪循環になります。受信と処理を分けるのが先です。
順序は保証されません。products/updateがproducts/createより先に届くことがあります。公式は、ヘッダーのX-Shopify-Triggered-Atか、ペイロードのupdated_atで並べ替えるよう案内しています。さらに、Webhookの取りこぼしに備えて、updated_atで絞った取得を定期的に回す同期ジョブも勧めています。受信側だけで完全性は担保できません。
もう1つ、include_fieldsで項目を絞ると、同一内容の配信が短時間に続いたとき、後の配信が捨てられます。Shopifyはこれを重複排除(debouncing)と呼びます。対策は、下の「よくあるつまずき」に書きました。
よくあるつまずき
落とし穴
express.json()を先に通して署名が合わない
HMACは未加工のボディから計算します。express.json()を先に置くと、ボディが解析済みのオブジェクトになり、署名が一致しません。Webhookのルートにはexpress.rawを使い、検証をボディ解析より前に置きます。
落とし穴
ヘッダーを大文字キーで読んで、全件が401になる
Node.jsのhttpモジュールはヘッダー名を小文字にして渡します。headers["X-Shopify-Hmac-Sha256"]のように大文字混じりで引くとundefinedになり、署名検証は常に失敗します。上の実装のように小文字のキーで読みます。
落とし穴
解析したJSONを書き戻して署名を計算する
JSON.parseしたオブジェクトをJSON.stringifyし直すと、空白やキーの並びが変わり、元のバイト列と一致しません。検証に渡すのは、受け取ったままのBuffer(または文字列)です。
落とし穴
include_fieldsに毎回変わる値を入れず、後続の配信が消える
絞り込んだ項目が同じ配信が続くと、後の配信は重複排除で捨てられます。更新を取りこぼしたくないトピックでは、updated_atのように配信ごとに変わる項目をinclude_fieldsに含めます。
まとめ
重複排除はWebhook-Id、順序の判断はTriggered-Atかupdated_at、署名は未加工のボディ。この3つを規約とテストの形で先に渡すと、正常系だけが通る実装を避けられます。準拠トピックの抜けは、通常のテストでは見えません。設定ファイルの編集時に走るフックで、審査の前に拾います。永続ストアを使う本番版では、一意制約を使った挿入に置き換えてください。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。