# Shopify の Webhook の仕様：HMAC 検証・5 秒のタイムアウト・再送と購読の削除

> Shopify の Webhook の HMAC 検証、1 秒と 5 秒のタイムアウト、4 時間で 8 回の再送と購読の削除、重複と順番を公式で確かめて表にし、HMAC が合わない 3 つの原因と Node の検証のコードを示します。

公開日: 2026-11-07
Source: https://webhookadmin.com/blog/shopify-webhooks-hmac-retries/

## 要点

- Shopify は、応答が 5 秒以内に返らないか 2xx 以外のとき、4 時間で最大 8 回再送します。8 回続けて失敗すると、Admin API で作った購読は自動で削除されます。
- 署名は X-Shopify-Hmac-Sha256 ヘッダーの base64 の HMAC-SHA256 で、鍵はアプリの client secret、対象は生の本文です。
- HMAC が合わない原因の多くは、JSON に変換した後の本文で計算している、hex で比べている、鍵を取り違えている、の 3 つです。

Shopify の Webhook は、応答が 5 秒以内に返らないか 2xx 以外だった場合、4 時間で最大 8 回再送されます。8 回続けて失敗すると、Admin API で作った購読は自動で削除されます。署名は `X-Shopify-Hmac-Sha256` ヘッダーに base64 で入った HMAC-SHA256 で、鍵はアプリの client secret、計算の対象は届いたままの本文です。この記事では、2026-09-27 に Shopify の公式のドキュメントで確かめた仕様を表にまとめ、HMAC が合わないときの原因と、受け口の組み方を示します。

## 仕様の一覧

| 項目 | 仕様 | 出典 |
|---|---|---|
| 署名のヘッダー | `X-Shopify-Hmac-Sha256`（HTTPS の配信だけ） | [配信の形](https://shopify.dev/docs/apps/build/webhooks/delivery-structure) |
| 署名の方式 | HMAC-SHA256、base64 | [HTTPS の配信](https://shopify.dev/docs/apps/build/webhooks/subscribe/https) |
| 署名の鍵 | アプリの client secret | 同上 |
| 署名の対象 | 生の本文（`express.json()` などで変換する前） | 同上 |
| 接続のタイムアウト | 1 秒 | 同上 |
| 要求全体のタイムアウト | 5 秒 | 同上 |
| 成功の判定 | 200 番台 | [トラブルシューティング](https://shopify.dev/docs/apps/build/webhooks/troubleshoot) |
| 再送 | 失敗すると、その後 4 時間で 8 回 | [HTTPS の配信](https://shopify.dev/docs/apps/build/webhooks/subscribe/https) |
| 購読の削除 | 8 回続けて失敗すると、Admin API で作った購読を自動で削除 | 同上 |
| 警告のメール | アプリの緊急連絡用の開発者のメールアドレスに送る | 同上 |
| 重複の見分け | `X-Shopify-Webhook-Id`（配信ごとに一意） | [重複の無視](https://shopify.dev/docs/apps/build/webhooks/ignore-duplicates) |
| 順番 | 同じトピックの中でも、同じリソースの別のトピックの間でも保証しない | [Webhook の概要](https://shopify.dev/docs/apps/build/webhooks) |
| 届くことの保証 | 常に届くとは限らない。定期的な照合を勧める | 同上 |

## 届くヘッダー

| ヘッダー | 中身 |
|---|---|
| `X-Shopify-Topic` | トピックの名前（例: `products/update`） |
| `X-Shopify-Hmac-Sha256` | base64 の HMAC の署名 |
| `X-Shopify-Shop-Domain` | イベントを起こしたストアの `myshopify.com` のドメイン |
| `X-Shopify-API-Version` | 本文の形に使った API の版 |
| `X-Shopify-Webhook-Id` | 配信ごとに一意のキー。重複の除去に使う |
| `X-Shopify-Triggered-At` | Shopify が配信を起こした時刻 |
| `X-Shopify-Event-Id` | 同じ操作から出たすべての配信に共通の ID |
| `X-Shopify-Name`（任意） | 購読に付けた名前 |

Shopify は、HTTP/2 ではヘッダーの名前が小文字になることが多いので、コードでは大文字と小文字を区別せずに扱うよう書いています。

## HMAC が合わない 3 つの原因

Shopify の開発者フォーラムには、HMAC の検証の失敗の相談が繰り返し出ています（[NestJS で合わない例](https://community.shopify.dev/t/fail-to-verify-hmac-x-shopify-hmac-sha256-header-in-nestjs/5092)、[技術的な支援を求める例](https://community.shopify.dev/t/hmac-webhook-verification-failing-need-technical-assistance/18871)）。原因はほとんどが次の 3 つです。

1. **JSON に変換した後の本文で計算している**。Shopify の説明も「`express.json()` のような本文の変換のミドルウェアを使っていると、検証のコードより先に本文が変換される」と注意しています。
2. **hex で比べている**。ヘッダーの値は base64 です。`digest('hex')` の値と比べても一致しません。
3. **鍵を取り違えている**。鍵は Webhook ごとの値ではなく、アプリの client secret です。開発用と本番用のアプリで client secret が違う点にも注意します。

## Node での検証

Shopify のドキュメントの例と同じく、生の本文から base64 の HMAC を計算し、定数時間で比べます。

```ts
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET!;

// Webhook の経路だけ本文を Buffer のまま受け取る。express.json() より前に置く
app.post('/webhooks/shopify', express.raw({ type: 'application/json' }), async (req, res) => {
  const received = req.get('x-shopify-hmac-sha256') ?? '';
  const digest = crypto.createHmac('sha256', CLIENT_SECRET).update(req.body).digest('base64');

  const a = Buffer.from(digest, 'base64');
  const b = Buffer.from(received, 'base64');
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);

  // 重複の除去: 配信ごとに一意の X-Shopify-Webhook-Id を一意制約つきで記録
  const webhookId = req.get('x-shopify-webhook-id')!;
  const r = await db.query('INSERT INTO shopify_deliveries (id) VALUES ($1) ON CONFLICT DO NOTHING', [webhookId]);
  if (r.rowCount === 0) return res.sendStatus(200);

  await queue.add('shopify', {
    topic: req.get('x-shopify-topic'),
    shop: req.get('x-shopify-shop-domain'),
    triggeredAt: req.get('x-shopify-triggered-at'),
    body: req.body.toString('utf8'),
  });
  res.sendStatus(200); // 5 秒以内に返す
});

app.use(express.json());
```

NestJS では、`NestFactory.create(AppModule, { rawBody: true })` で起動すると、`RawBodyRequest` の `req.rawBody` で変換前の本文（Buffer）を受け取れます（[NestJS のドキュメント](https://docs.nestjs.com/faq/raw-body)）。ほかのフレームワークでの取り方は[生の本文の取り方の記事](https://webhookadmin.com/blog/webhook-raw-body-by-framework/)で扱います。

## 5 秒以内に返す受け口

5 秒のタイムアウトは、主要なサービスの中でも短い部類です。各社のタイムアウトの秒数の比較は[タイムアウトの記事](https://webhookadmin.com/blog/webhook-timeouts/)で扱います。Shopify のトラブルシューティングも「タイムアウトの失敗を直すには、応答を返した後まで処理を遅らせる」よう書いています。受け口では次のことだけを行います。

1. HMAC を検証する。
2. `X-Shopify-Webhook-Id` で重複を弾く。
3. キューに入れる。
4. 200 を返す。

商品の同期や外部の API の呼び出しは、キューの先のワーカーで行います。Shopify は接続を使い回すために Keep-Alive を有効にすることも勧めています。

## 再送と購読の削除への備え

8 回の再送は失敗から 4 時間で終わり、8 回続けて失敗した購読は消えます。受け口が 4 時間以上止まると、その間のイベントは届かず、購読も作り直しになります。

- **購読が消えたら**: 原因を直してから購読を作り直します。消えていた間のイベントは再送されないので、Admin API でデータを取り直して状態を合わせます。
- **定期的な照合**: Shopify は、Webhook が常に届くとは限らないとして、定期的に Shopify からデータを取り直す照合の仕事を勧めています。障害がなくても、日に 1 回などの照合を用意しておくと取りこぼしに気づけます。
- **警告のメールの宛先**: 失敗の警告はアプリの緊急連絡用の開発者のメールアドレスに届きます。個人のアドレスではなく、チームで読めるアドレスにしておきます。
- **配信の状況**: Dev Dashboard のアプリの Overview と Logs で、配信の状況を確かめられます。

## 順番の扱い

Shopify は順番を保証しないとしたうえで、ヘッダーの `X-Shopify-Triggered-At` か、本文の `updated_at` で並べるよう勧めています。同じリソースの更新が前後して届いたときは、保存している `updated_at` より古い更新を捨てる形にします。

```sql
UPDATE products
SET title = :title, updated_at = :updated_at
WHERE id = :id AND (updated_at IS NULL OR updated_at < :updated_at);
```

重複の扱いは[重複と冪等性の記事](https://webhookadmin.com/blog/webhook-idempotency-duplicates/)で、署名の失敗の切り分けは[署名検証が失敗する原因の記事](https://webhookadmin.com/blog/webhook-signature-verification-failed/)で詳しく扱いました。

## 自社が送る側になるとき

Shopify のように自社のサービスから顧客やアプリへ Webhook を送る側になるなら、再送・署名・配信の記録・失敗の通知を用意する必要があります。再送の期間の決め方は[再送の設計の記事](https://webhookadmin.com/blog/webhook-retry-design/)で扱いました。

Webhook Admin は、これらを API から使えます。署名は Standard Webhooks の形式で、再送は既定では約 28 時間で 8 回（送信先ごとに回数と間隔を選べます）、タイムアウトは 15 秒で、送信ごとの応答のコードと応答の先頭 1,024 バイトを記録します。失敗が続くと Slack・Teams・Chatwork・メール・Webhook で知らせ、5 日続いた送信先は自動で止めます（止めた送信先は API で再開できます）。

自社の送信の仕組みを作る前に試したい場合は、Webhook Admin を無料で登録できます（月 5 万通まで）。[https://app.webhookadmin.com/signup](https://app.webhookadmin.com/signup)

## よくある質問

### HMAC を hex で比べると失敗するのはなぜですか。

Shopify は X-Shopify-Hmac-Sha256 に HMAC-SHA256 の値を base64 で入れて送るからです。計算した値を digest('hex') で文字列にすると、同じバイト列でも表記が違うので一致しません。digest('base64') で計算するか、ヘッダーの値を base64 で戻したバイト列と比べます。

### 購読が消えてしまったら、どうすれば戻せますか。

受け口の問題を直してから、購読を作り直します。Shopify のトラブルシューティングも、原因を直してから Webhook の購読を作り直すよう書いています。消えていた間のイベントは届かないので、Admin API でデータを取り直して状態を合わせます。

### 重複はどう見分けますか。

X-Shopify-Webhook-Id ヘッダーを使います。Shopify は、この値を永続的な保存先で確かめ、既にあれば処理を飛ばして成功を返し、無ければ処理して保存するよう書いています。X-Shopify-Event-Id は同じ操作から出た複数の配信に共通の値なので、重複の判定には使いません。
