土曜の深夜、Slackが鳴り続けた
「タイムセールの初日に、同じ注文の出荷指示が3PLに3回飛んだ」— 先月、あるアパレルECの運営担当者から聞いた話だ。Shopifyのorders/create webhookが同一注文IDで3回発火し、出荷直前に倉庫側が気づいて事なきを得たという。事務所の壁に貼られた手書きの再発防止メモを見せてもらった。「webhookは1回しか来ない、と思い込んでいた」と書いてあった。
製造業の見積り・図面まわりの話は 前回の記事 にまとめたが、ECには別の罠がある。注文・在庫・CS、この3つは図にすると単純なフローなのに、自動化を始めた瞬間に重複・整合性・応答品質が一気に崩れる。本稿は、筆者が日本のEC事業者3社で実装したコードと、そこで30分以上ハマったポイントの覚書である。
前提と環境
Node.js 20 + TypeScript 5.x、Shopify Admin API(REST/GraphQLを用途で使い分け)、webhook受信はCloud Run、キューはCloud Tasks、idempotency tableはPostgreSQL。LLMはCS応答の分類と下書きにOpenAI gpt-4o。通知はSlack Webhook。3社とも基本構成は同じで、違いは「どのレイヤーから着手したか」だけだった。アパレル系のA社は注文の重複処理、健康食品のB社はCS応答、雑貨多店舗のC社は在庫一元化。順に書く。
注文処理 — webhookは重複する前提で書く
Shopifyのorders/create webhookは、トラフィックが急増した時に同じorder_idで複数回飛んでくる。公式ドキュメントの片隅にat-least-onceと書いてあるのだが、最初に読んだ時は読み飛ばしていた。実装してみるまで実感がなかった、というのが正直なところだ。
import express from "express";
import crypto from "crypto";
import { Pool } from "pg";
const app = express();
const pg = new Pool({ connectionString: process.env.DATABASE_URL });
const verifyHmac = (raw: Buffer, hmac: string): boolean => {
const digest = crypto
.createHmac("sha256", process.env.SHOPIFY_WEBHOOK_SECRET || "YOUR_WEBHOOK_SECRET")
.update(raw)
.digest("base64");
// 長さが違うとtimingSafeEqualが例外を吐く。先にチェックする
if (digest.length !== hmac.length) return false;
return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(hmac));
};
app.post(
"/webhooks/orders-create",
express.raw({ type: "application/json" }),
async (req, res) => {
const hmac = req.get("X-Shopify-Hmac-SHA256") || "";
const raw = req.body as Buffer;
if (!verifyHmac(raw, hmac)) return res.status(401).send("invalid hmac");
// Shopifyは5秒以内のレスポンスを要求する。重い処理の前に200を返す
res.status(200).send("ok");
const payload = JSON.parse(raw.toString("utf8"));
const orderId = String(payload.id);
// 重複排除: 同じorder_idがすでにあれば後続を打ち切る
const inserted = await pg.query(
`INSERT INTO processed_orders (order_id, status, received_at)
VALUES ($1, ''received'', NOW())
ON CONFLICT (order_id) DO NOTHING
RETURNING order_id`,
[orderId]
);
if (inserted.rowCount === 0) {
console.log(`[dedup] order ${orderId} already in flight, skip`);
return;
}
await enqueueFulfillment(orderId, payload);
}
);
ハマりポイントは2つ。一つはHMAC検証をexpress.json()でparseする前にやること。bodyが正規化されると署名が合わなくなる。もう一つは応答を先に返すこと。Shopifyは5秒以内に2xxが返らないとリトライを始め、結果として重複webhookがさらに増える悪循環になる。最初の実装で筆者はこの順序を逆にしていて、本番投入の前夜に気づいた。
3PLルーティングは送り先の都道府県コードと商品の在庫位置で分岐させた。A社は関東/関西の2倉庫、B社は外部3PL一択、雑貨系のC社はShopify以外に楽天とAmazonの注文も同じパイプラインに流すので、normalizerを一段噛ませている。
type NormalizedOrder = {
channel: "shopify" | "rakuten" | "amazon";
externalId: string;
shipTo: { region: string; postal: string };
lines: { sku: string; qty: number }[];
};
const routeToWarehouse = (o: NormalizedOrder): "east" | "west" | "dropship" => {
const kanto = ["13", "14", "11", "12", "08", "09", "10"];
const kansai = ["27", "28", "26", "25", "29", "30"];
if (kanto.includes(o.shipTo.region)) return "east";
if (kansai.includes(o.shipTo.region)) return "west";
return "dropship";
};
在庫同期 — 「マイナス在庫」を絶対に出さない
多店舗運営のC社で最初に起きた事故は、Shopifyの在庫が「10」と表示されたまま楽天で20件売れた瞬間に発生した。差分を埋めるだけの単純な処理ではダメで、マスター在庫の所在を1箇所に決める必要があった。C社の場合はWMSをマスターに、Shopify/楽天/Amazonはそのreplicaとして扱う設計に変えた。
inventory_levels/update webhookは、Shopify側で在庫が動いた時に飛んでくる。これをWMSに反映する時に、「自分が書いた更新をまた受信する」ループに入りやすい。対策は更新元のタグをmetafieldに残しておき、自分由来のイベントは無視するだけ。地味だが効く。
type InventoryUpdate = {
inventory_item_id: number;
location_id: number;
available: number;
updated_at: string;
};
const handleInventoryUpdate = async (u: InventoryUpdate) => {
const tag = await getUpdateSourceTag(u.inventory_item_id);
if (tag === "wms-sync") {
// 自分が書いた更新の反射。無視する
return;
}
// しきい値を切ったらSlackに通知し、ドラフト発注書を作る
if (u.available < 10) {
await slackNotify({
text: `低在庫アラート: item ${u.inventory_item_id} の在庫が ${u.available} になりました`,
});
await createDraftPurchaseOrder(u.inventory_item_id);
}
};
もう一つ。Shopify Admin APIのrate limitはstandardプランで40 req/sec、Plusで80 req/sec。フラッシュセール中に在庫の一括更新を流すと、普通に詰まる。指数バックオフ + bucketingで、1秒あたり30 reqに抑える運用にしている。秒30と聞くと余裕に見えるが、商品が3万SKUある店だと1往復に16分以上かかる。実際にやってみるとドキュメントには書かれていない時間感覚がここにある。
CS応答 — LLMに「注文データを見せる」だけで景色が変わる
健康食品のB社は、サポート流入の7割が「私の注文どうなってる?」だった。FAQボットでは答えられない。なぜなら、答えは過去24時間の注文DBの中にしかないからだ。
そこで、LLMにメッセージ分類と回答下書きをさせる前に、まずその顧客の直近の注文5件をプロンプトに差し込む仕組みを入れた。コードはこうなる。
import OpenAI from "openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY || "YOUR_API_KEY" });
type Intent = "shipping_status" | "return_request" | "product_question" | "complaint" | "other";
type DraftResult = { intent: Intent; reply: string; confidence: number };
const classifyAndDraft = async (
message: string,
customerEmail: string
): Promise<DraftResult> => {
const orders = await fetchRecentOrders(customerEmail, 5);
const orderCtx = orders.length
? orders
.map(
(o) =>
`#${o.name} (${o.created_at.slice(0, 10)}): ${o.fulfillment_status ?? "未発送"} / 追跡: ${o.tracking_number ?? "未登録"}`
)
.join("\n")
: "(過去90日に注文なし)";
const sys = `あなたはECサポートの下書き担当である。
- 出力は必ずJSON
- 注文情報がある場合は必ず引用する
- 追跡URLは出力しない (人間が後で追加する)
- 不確実な場合はconfidenceを下げ、人間に回す`;
const user = `# 顧客メッセージ
${message}
# この顧客の直近の注文
${orderCtx}
# 出力スキーマ
{ "intent": "shipping_status|return_request|product_question|complaint|other",
"reply": "100~200字の敬語日本語",
"confidence": 0.0~1.0 }`;
const resp = await openai.chat.completions.create({
model: "gpt-4o",
response_format: { type: "json_object" },
temperature: 0.2,
messages: [
{ role: "system", content: sys },
{ role: "user", content: user },
],
});
const parsed = JSON.parse(resp.choices[0].message.content ?? "{}") as DraftResult;
// confidenceが0.7未満ならhuman-in-the-loop
if (parsed.confidence < 0.7) {
await escalateToHuman(customerEmail, message, parsed);
}
return parsed;
};
ここで一度立ち止まった。LLMがhallucinationして「お客様の注文#1234は本日発送予定です」と存在しない注文番号を返すリスクがある。対策は出力をparseした後で、replyに含まれる注文番号がorderCtxに登場するものだけか、をバリデーションすること。地味な後処理だが、これを入れないと現場の信頼を失う。
導入後3ヶ月で、B社のCS一次応答時間は平均8時間から12分に縮んだ。ただし内訳を見ると、自動応答で完結したのは47%、残りはオペレーターが下書きを編集してから返信している。47%と聞くと小さく見えるが、深夜帯の応答がゼロだったものが「即時返信」になる体験変化のほうが、数字以上に大きかった、とB社の責任者は言っていた。
動作確認 — ログから見る一日の流れ
[2026-05-22 09:14:21] orders/create received order_id=5821443102 channel=shopify
[2026-05-22 09:14:21] [dedup] order 5821443102 inserted, processing
[2026-05-22 09:14:22] route3PL order=5821443102 region=27 → west
[2026-05-22 09:14:22] enqueued fulfillment task: tasks/.../5821443102
[2026-05-22 09:14:22] orders/create received order_id=5821443102 channel=shopify
[2026-05-22 09:14:22] [dedup] order 5821443102 already in flight, skip
[2026-05-22 09:14:33] inventory_levels/update item=42118 location=east available=8
[2026-05-22 09:14:33] [low-stock] slack notified, draft PO created PO-2026-0518
[2026-05-22 09:21:08] cs.classify intent=shipping_status confidence=0.92
[2026-05-22 09:21:08] cs.reply autoSent customer=k***@example.com
[2026-05-22 11:02:44] cs.classify intent=complaint confidence=0.61
[2026-05-22 11:02:44] cs.escalate human-in-the-loop assigned=operator_3
重複webhookが想定通り捨てられ、低在庫からドラフト発注書まで連動し、CSは確信度でルーティングされている。設計時に書いた状態遷移図とほぼ同じ動きになると、ようやく息が吐ける。
応用と発展
3社の実装を振り返って、他業種に転用できそうな発展方向がいくつかある。BtoB ECなら、注文確認→与信チェック→請求書発行をorders/createの後段にぶら下げる構成が綺麗だ。ただし与信は別マイクロサービスにして200ms以内に返さないとUI体験が崩れる。サブスクリプション系では、定期注文のorders/createに「次回配送日の自動シフト」を載せると、サポートに集中していた「次回をずらしたい」系の連絡が大幅に減る。マーケットプレイスの出店者向けには、Shopify/楽天/Amazon/Yahoo!ショッピングの4店舗を1つのWMSに集約するオーケストレーター設計を、後発のSaaSが取り始めている。設計の起点はどれも同じで、「単一マスターをどこに置くか」を先に決めるだけだ。
CSのhuman-in-the-loop UIには余談がある。下書きを編集→送信した内容をデータセットに溜めると、ファインチューニングをやる前に、まずプロンプト改善で半分以上の精度が上がる。最初からfine-tuneに走らない方が、費用対効果も意思決定の速度も上だ。
まとめ
注文・在庫・CSのどれも、最初に書き始めるコードはシンプルだ。複雑になるのは「重複」「整合性」「不確実性」が同時に立ち上がる瞬間で、設計の早い段階でこの3つに名前をつけて分けておくと、後から人が増えても話が通じる。次回は、ここから一歩外に出る話 — 物流のラストワンマイル、トラッキングと通関のパイプラインを3社分の実装メモとして書く予定だ。
筆者は 5years+ で日本市場向けの AI 自動化の設計を担当している。本稿のコードはどれも実プロジェクトから抽象化したもので、業種が違っても考え方は流用できるはずだ。