目次
はじめに
Shopee の注文同期を構築するとき、最初は「Webhook だけでいいじゃないか」と考えがちだ。しかし本番運用に入ると Webhook の配信遅延・配信失敗・Lambda の一時的な障害などで注文欠損が起きる。
「欠損はあとで手動対応」で済む規模ならよいが、注文数が増えると手動対応は破綻する。ここでは Webhook をメイン、定期ポーリングをバックアップ にした二重受信アーキテクチャを解説する。
アーキテクチャ全体像
【メイン】Shopee Webhook (order.status)
↓
SQS Queue
↓
Lambda: 注文処理
【バックアップ】EventBridge Scheduler(5分ごと)
↓
Lambda: 差分ポーリング
↓ (Webhook で処理済みでないものだけ)
SQS Queue
↓
Lambda: 注文処理(同じ処理ロジック)
どちらの経路から入っても同じ SQS キューを経由し、同じ Lambda で処理することで、処理ロジックを 1 箇所に集約できる。
Webhook の受信と署名検証
Shopee の Webhook には HMAC-SHA256 署名が付く。受信側で必ず検証する。
// app/api/shopee/webhook/route.ts
import crypto from "crypto"
export async function POST(request: Request) {
const body = await request.text()
const authorization = request.headers.get("authorization") ?? ""
// 署名検証
const expectedSignature = crypto
.createHmac("sha256", SHOPEE_PARTNER_KEY)
.update(`${SHOPEE_PARTNER_ID}|${new URL(request.url).pathname}|${body}`)
.digest("hex")
if (authorization !== expectedSignature) {
return new Response("Unauthorized", { status: 401 })
}
const event = JSON.parse(body)
// 注文イベントのみキューイング
if (event.code === 15) {
// code 15: order_status_update
await enqueueOrderSync(event.data.ordersn)
}
return new Response("OK", { status: 200 })
}
async function enqueueOrderSync(orderSn: string) {
await sqs.sendMessage({
QueueUrl: ORDER_QUEUE_URL,
MessageGroupId: "shopee-orders",
MessageDeduplicationId: orderSn, // 同一 orderSn は SQS FIFO で重複排除
MessageBody: JSON.stringify({ orderSn, source: "webhook" }),
})
}バックアップポーリング: 差分ポーリング
5 分ごとに getOrderList を叩き、直近 10 分分の注文を取得する。「直近 10 分」としてウィンドウを重複させることで、境界付近の注文欠損を防ぐ。
async function backupPolling(shopId: number, accessToken: string) {
const timeFrom = Math.floor(Date.now() / 1000) - 10 * 60 // 10 分前
const timeTo = Math.floor(Date.now() / 1000)
const result = await callShopeeApi(
"/api/v2/order/get_order_list",
{
time_range_field: "create_time",
time_from: timeFrom,
time_to: timeTo,
page_size: 100,
response_optional_fields: "order_status",
},
shopId,
accessToken
)
const orders = result.response?.order_list ?? []
for (const order of orders) {
// Webhook で既に処理済みかチェック
const processed = await isAlreadyProcessed(order.order_sn)
if (!processed) {
await enqueueOrderSync(order.order_sn)
}
}
}処理済みチェック: DynamoDB の TTL を活用
Webhook で処理した注文の order_sn を DynamoDB に記録しておき、バックアップポーリング側で「処理済みか」を確認する。TTL は 30 分に設定しておけば、コストを抑えつつポーリングの重複取得ウィンドウをカバーできる。
async function isAlreadyProcessed(orderSn: string): Promise<boolean> {
const item = await ddb.get({
TableName: "shopee-processed-orders",
Key: { pk: `order:${orderSn}` },
})
return !!item.Item
}
async function markAsProcessed(orderSn: string) {
const ttl = Math.floor(Date.now() / 1000) + 30 * 60 // 30分後に自動削除
await ddb.put({
TableName: "shopee-processed-orders",
Item: { pk: `order:${orderSn}`, ttl },
})
}注文詳細の取得と処理
SQS から注文 SN を受け取った Lambda で詳細を取得して処理する。
async function processOrder(orderSn: string, shopId: number, accessToken: string) {
// 注文詳細の取得
const result = await callShopeeApi(
"/api/v2/order/get_order_detail",
{
order_sn_list: [orderSn],
response_optional_fields: [
"buyer_username",
"item_list",
"recipient_address",
"actual_shipping_fee",
].join(","),
},
shopId,
accessToken
)
const order = result.response?.order_list?.[0]
if (!order) throw new Error(`Order not found: ${orderSn}`)
// WMS へ連携
await sendToWms(order)
// 処理済み記録
await markAsProcessed(orderSn)
}アラート設計: 欠損検知
24 時間ごとに「過去 24 時間の受注数(Shopee API から取得)と OMS の登録数が一致しているか」を比較するバッチを走らせる。差異があればアラートを上げることで、欠損を早期発見できる。
async function dailyOrderAudit() {
const shopeeCount = await countShopeeOrders(/* 過去24時間 */)
const omsCount = await countOmsOrders(/* 過去24時間 */)
if (shopeeCount !== omsCount) {
await sendAlert(`注文欠損検知: Shopee=${shopeeCount}, OMS=${omsCount}`)
}
}まとめ
| コンポーネント | 役割 |
|---|---|
| Webhook | メイン受信(低レイテンシ) |
| 定期ポーリング(5分) | バックアップ(欠損救済) |
| DynamoDB TTL | 重複処理の排除(30分) |
| 日次監査バッチ | 欠損の早期検知 |
Webhook + ポーリングの二重受信は少し実装コストがかかるが、一度作れば注文欠損に悩む日常運用から解放される。




