TECH JOURNAL

Shopify Webhook の重複処理を完全に防ぐ — 冪等性キーと DynamoDB による実装

ShareXB!
Shopify Webhook の重複処理を完全に防ぐ — 冪等性キーと DynamoDB による実装
目次

はじめに

Shopify の公式ドキュメントには「Webhook は at-least-once で配信される」と明記されている。つまり同じイベントが 2 回以上届く可能性がある。これを前提として設計していないと、以下の事故が起きる。

  • 注文確定 Webhook が 2 回届き、WMS に注文が 2 件登録された
  • order/fulfilled イベントで顧客への発送通知メールが重複送信された
  • 在庫差し引きが 2 回実行されてマイナス在庫になった

Shopify は各 Webhook 配信に X-Shopify-Webhook-Id というユニーク ID を付与する。これを冪等キーとして使えば、重複処理を完全に防げる。

冪等性の実装パターン

基本構造

Shopify Webhook 受信
    ↓
X-Shopify-Webhook-Id を DynamoDB に記録(条件付き Put)
    ├── 成功(初回)→ 実際のビジネスロジックを実行
    └── 失敗(重複)→ 200 を返してスキップ

200 を返すのが重要。5xx や 4xx を返すと Shopify がリトライを続けてしまう。

DynamoDB による重複排除

import { DynamoDBClient, PutItemCommand, ConditionalCheckFailedException } from "@aws-sdk/client-dynamodb"
 
const ddb = new DynamoDBClient({})
 
async function isFirstDelivery(webhookId: string): Promise<boolean> {
  const ttl = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60 // 7日後に自動削除
 
  try {
    await ddb.send(new PutItemCommand({
      TableName: "webhook-idempotency",
      Item: {
        pk: { S: `webhook:${webhookId}` },
        ttl: { N: String(ttl) },
        receivedAt: { S: new Date().toISOString() },
      },
      ConditionExpression: "attribute_not_exists(pk)",
    }))
    return true // 初回
  } catch (error) {
    if (error instanceof ConditionalCheckFailedException) {
      return false // 重複
    }
    throw error
  }
}

Next.js API Route での実装例

// app/api/webhooks/shopify/orders-create/route.ts
import { headers } from "next/headers"
import crypto from "crypto"
 
export async function POST(request: Request) {
  const body = await request.text()
  const headersList = headers()
 
  // 1. HMAC 署名検証
  const hmac = headersList.get("x-shopify-hmac-sha256")
  const expected = crypto
    .createHmac("sha256", process.env.SHOPIFY_WEBHOOK_SECRET!)
    .update(body)
    .digest("base64")
 
  if (hmac !== expected) {
    return new Response("Unauthorized", { status: 401 })
  }
 
  // 2. 冪等性チェック
  const webhookId = headersList.get("x-shopify-webhook-id")!
  const isFirst = await isFirstDelivery(webhookId)
 
  if (!isFirst) {
    console.log(`Duplicate webhook skipped: ${webhookId}`)
    return new Response("OK", { status: 200 }) // 200 を返してリトライを止める
  }
 
  // 3. ビジネスロジック
  const order = JSON.parse(body)
  await processNewOrder(order)
 
  return new Response("OK", { status: 200 })
}

HMAC 署名検証を最初に置く理由

冪等性チェック(DynamoDB への書き込み)より先に HMAC 検証を行う。順序を逆にすると、悪意のあるリクエストで DynamoDB にゴミデータが蓄積するリスクがある。

べき等キーの TTL を適切に設定する

DynamoDB の TTL は長すぎるとコストがかさみ、短すぎると Shopify のリトライ間隔(最長 48 時間)をカバーできない。7 日 を目安にすると Shopify の最大リトライ期間を十分にカバーできる。

冪等性だけでは足りないケース

冪等性チェックをパスした「初回処理」が途中で失敗した場合、次の再配信は「重複」と判定されてスキップされる。このとき処理が中途半端な状態で止まる。

対策は 2 フェーズ処理。

  1. DynamoDB に status: "processing" で記録
  2. ビジネスロジックを実行
  3. 完了後に status: "completed" に更新

再配信時に status: "processing" を見つけたら、前回の途中から再実行するリカバリーロジックを入れる。

まとめ

リスク対策
Webhook の重複配信DynamoDB 条件付き Put で排除
不正リクエストHMAC 検証を最初に実施
初回処理の中断2 フェーズ処理 + ステータス管理
DynamoDB コスト増大TTL で 7 日後に自動削除
ShareXB!

この記事を書いた人

渡部 誠也

執行役員 / CTO

独立系 SIer で Web・組み込み・基幹システムの開発を経験し、2017 年に illustrious へ。CTO としてシステム開発事業を立ち上げ、要件定義からコーディングまで一貫して担う。EC に特化した Web アプリケーションを数多く手がける。

ECの業務やシステムについて、
ご相談ください。

いまの運用で困っていること、実現したいことから、一緒に整理します。