月曜の朝、売上レポートを開いて気づきます。先週いちばん売れていた商品が、金曜の夕方から在庫切れになっていました。土日の 2 日間、この商品は 1 個も売れていません。広告は回り続けていたので、広告費だけが出ていきました。
在庫切れは、売れるはずだった売上がそのまま消える損失です。しかも、何個売り逃したかは誰にも分かりません。売上レポートには「売れなかった」としか記録されないので、損失の大きさを測ることすらできません。
Shopify の標準機能には、在庫が減ってきたことを能動的に知らせる仕組みがありません。在庫画面を自分で見に行くしかないため、この種の欠品はどのストアでも起こります。
では、どうやって仕組みを作るか。選択肢は「アプリを入れる」か「自分で実装する」かの 2 つです。この記事では、自作した場合に必ずぶつかる 4 つの壁を具体的なコードとともに示し、そのうえでアプリという選択肢を比較します。
まず、自作する場合の設計を整理します。Shopify は在庫レベルが更新されたときに Webhook を送れるので、これを受けて判定し、通知を送ります。
処理の流れはこうなります。
在庫が動く → Shopify が Webhook を送る → アプリが受け取る → 対象のバリエーションとロケーションを特定する → しきい値を解決する → 状態を判定する → 通知すべきか判断する → 通知を送る → 状態を保存する
一見すると素直な流れです。実際、動くものを作るだけなら 1 日で書けます。問題は、そのあとに 4 つの壁が待っていることです。
最初にぶつかるのがこれです。
在庫は注文が入るたびに変化します。しきい値を下回った状態で注文が続くと、注文のたびに Webhook が飛び、そのたびに通知が送られます。「在庫 10 以下で通知」の設定で在庫 8 の商品が 5 個売れれば、5 通届きます。セール中の売れ筋商品なら、1 日で数十通になります。
素朴に書くとこうなります。
// これは動くが、通知が鳴り止まない
export async function handleInventoryUpdate(payload) {
const { inventory_item_id, location_id, available } = payload;
const threshold = await resolveThreshold(inventory_item_id, location_id);
if (available <= threshold) {
await sendNotification({ inventory_item_id, location_id, available, threshold });
}
}
通知すべきかどうかは、いまの在庫数だけでは決まりません。前回どの状態だったかと比べて初めて決まります。
状態を 4 段階で持ちます。正常(ok)・低在庫(low)・緊急(critical)・在庫切れ(out_of_stock)。そのうえで、状態の遷移で判断します。
const SEVERITY = { ok: 0, low: 1, critical: 2, out_of_stock: 3 };
function judgeState(available, threshold, criticalThreshold) {
if (available <= 0) return 'out_of_stock';
if (criticalThreshold != null && available <= criticalThreshold) return 'critical';
if (available <= threshold) return 'low';
return 'ok';
}
export async function handleInventoryUpdate(payload) {
const { inventory_item_id, location_id, available } = payload;
const { threshold, criticalThreshold } =
await resolveThreshold(inventory_item_id, location_id);
const nextState = judgeState(available, threshold, criticalThreshold);
const previous = await getState(inventory_item_id, location_id);
// 初観測: 行が無い。low 以下なら通知する
if (!previous) {
await saveState(inventory_item_id, location_id, nextState, available);
if (SEVERITY[nextState] >= SEVERITY.low) {
await sendNotification({ inventory_item_id, location_id, available, threshold });
}
return;
}
const prevSeverity = SEVERITY[previous.state];
const nextSeverity = SEVERITY[nextState];
if (nextSeverity > prevSeverity) {
// 深刻度が上がった → 通知する
await saveState(inventory_item_id, location_id, nextState, available);
await sendNotification({ inventory_item_id, location_id, available, threshold });
return;
}
// 同じか、回復した → 状態だけ更新して通知しない
await saveState(inventory_item_id, location_id, nextState, available);
}
ここで一番大事なのは、最後の「回復したときも状態を更新する」処理です。
在庫が補充されて ok に戻ったとき、状態を更新しておかないと、次にしきい値を割ったときに「前回も low だった」と判定されて通知されません。一度通知した商品が、二度と通知されなくなるという最悪の失敗がここで起きます。しかもエラーは出ないので、誰も気づけません。
回復時に状態を戻すことを「再武装」と呼びます。通知を止める仕組みを作ったら、必ず再武装をセットで実装してください。
2 つ目の壁です。複数拠点で在庫を持っているストアでは、通知の単位が「商品」ではなく「商品 × ロケーション」になります。
東京倉庫に 50 個、大阪店に 3 個。合計 53 個なので在庫は潤沢に見えますが、大阪店では明日にも欠品します。合計値だけを見る実装では、この状況を検知できません。
さらに、しきい値も拠点で変わります。倉庫は補充のハブなので多めに持ちたい、店舗は棚の都合で少なめでよい。つまり、しきい値もロケーション軸を持つ必要があります。
// しきい値の解決順序: 個別(バリエーション×ロケーション) → 共通(バリエーション) → ストア既定
async function resolveThreshold(inventoryItemId, locationId) {
const specific = await findThreshold(inventoryItemId, locationId);
if (specific && specific.low != null) return specific;
// location_id = 0 を「全ロケーション共通」の意味で使う
const common = await findThreshold(inventoryItemId, 0);
if (common && common.low != null) return common;
const defaults = await getStoreDefaults();
return { threshold: defaults.low, criticalThreshold: defaults.critical };
}
ここを後から足すのは非常に痛い作業です。 状態テーブルにも、しきい値テーブルにも、通知履歴にもロケーション列が必要になるため、データの移行が発生します。最初からロケーション列を持たせておくのが唯一の正解です。単一拠点のストアでも、拠点が増える可能性はゼロではありません。
もう 1 つ、監視しないロケーションを外せる仕組みも要ります。返品用の倉庫やサンプル用のロケーションは、在庫が少なくて当たり前です。ここを通知対象に含めると、実務に無関係な通知でノイズが増えます。
3 つ目の壁です。ここは実装した人しか気づけません。
Webhook は必ず届く保証がありません。ネットワーク障害、アプリ側の一時的なダウン、Shopify 側の問題。理由はさまざまですが、届かなかった在庫変化は永久に検知されません。
しかも厄介なのは、届かなかったことに気づく手段がないことです。「通知が来ていない」のが「在庫が減っていないから」なのか「Webhook が届かなかったから」なのか、区別できません。
対策は、定期的に全在庫をスキャンして状態を突き合わせる処理を用意することです。
// 1 日 1 回など定期実行して、Webhook の取りこぼしを回収する
export async function scanInventory(admin, shopId) {
let cursor = null;
let scanned = 0;
const MAX = 10000; // 実行時間の上限に引っかからないよう打ち切る
while (scanned < MAX) {
const page = await fetchInventoryLevels(admin, { after: cursor, first: 250 });
for (const level of page.nodes) {
await handleInventoryUpdate({
inventory_item_id: level.item.legacyResourceId,
location_id: level.location.legacyResourceId,
available: level.quantities.available,
});
scanned += 1;
}
if (!page.pageInfo.hasNextPage) break;
cursor = page.pageInfo.endCursor;
}
await setLastScanAt(shopId, new Date());
return { scanned, truncated: scanned >= MAX };
}
ここでも落とし穴があります。商品点数が多いストアでは、スキャンが実行時間の上限に引っかかります。 サーバーレス環境なら数十秒〜数分で強制終了されるため、ページングと打ち切り処理が必須です。打ち切ったことを黙って隠すと「スキャンしたのに一部の商品が反映されない」という状態になるので、打ち切り件数を返して画面に出す必要もあります。
また、スキャンを連続で叩かれると API のレート制限に引っかかります。「前回から 5 分以内は実行しない」といったガードも要ります。
4 つ目の壁は、コードの外にあります。
しきい値をデータベースに持たせたところで、それを設定する画面がなければ運用できません。商品ごとに違う値を入れたいのに、その都度エンジニアに SQL を叩いてもらう、という運用は続きません。
必要になる画面は少なくとも 3 つです。
1. ストア既定値の設定画面。低在庫しきい値、緊急しきい値、在庫切れ通知の有無、通知先の登録。
2. 商品別しきい値の管理画面。商品を選ぶ UI(Shopify の商品選択画面を呼び出す)、一覧、編集、削除。ここで注意が必要なのは、商品選択の型です。バリエーションを直接選ぶ型を使うと、選択後の一覧に商品画像も商品名も出ず、バリエーション名だけの行になります。商品を選ぶ型にして、その中のバリエーションを選ばせるほうが、実際には使いやすくなります。
3. 低在庫の一覧画面。いま何が減っているかを見る画面です。ここがないと、通知を見逃したときに全体像を把握できません。
さらに、入力値の検証も画面とサーバーの両方に要ります。
// 空欄は null(既定値を使う)であって 0 ではない
export function parseThresholdInput(value) {
const trimmed = String(value ?? '').trim();
if (trimmed === '') return null; // Number('') が 0 になる罠を避ける
const n = Number(trimmed);
if (!Number.isInteger(n) || n < 0 || n > 999999) return undefined; // 不正
return n;
}
Number('') は 0 を返します。空欄を「既定値を使う」の意味で扱いたいのに、うっかり Number() に通すと 0 として保存されます。しきい値 0 は「在庫 0 以下で通知」という意味になるので、その商品だけ低在庫通知から黙って外れます。
負の値も同様に危険です。available <= -5 は永久に成立しないため、これも通知されなくなります。画面側の検証だけでは足りません。 緊急しきい値を空にしたときに低在庫へ負数を保存できてしまう、といった抜け道が残るため、サーバー側でも同じ検証を通す必要があります。
まとめると、自作の実際のコストはこうなります。
「通知を送る」部分は 1 日で書けます。壁1(重複抑制)に 2〜3 日、壁2(ロケーション対応)にテーブル設計から含めて 3〜5 日、壁3(定期スキャンとページング)に 2〜3 日、壁4(管理画面 3 つと検証)に 1〜2 週間。合計で 3〜4 週間というのが現実的な見積もりです。
そしてこれは初期構築のコストだけで、その後の保守が続きます。Shopify の API バージョンは定期的に更新され、Webhook のペイロード形式も変わり得ます。
自作が合理的なのは、既製品では満たせない要件がある場合だけです。 「社内の基幹システムに在庫状況を流し込みたい」「独自の需要予測ロジックと連動させたい」といった要件があるなら、自作の価値があります。単に「在庫が減ったら知らせてほしい」だけなら、月額 $8 前後のアプリのほうが総コストは確実に低くなります。

在庫がしきい値を割った瞬間にメール・Slack で知らせ、毎日/毎週のレポートも送れるアプリ。
特徴・機能
この記事で挙げた 4 つの壁が、そのまま実装されているアプリです。壁ごとに対応を見ていきます。
壁1(重複通知)については、状態が悪化したときだけ通知する設計になっています。深刻度が上がったときは通知し、同じ状態のまま在庫数だけ減った場合は状態だけを更新して通知しません。回復したときも状態を更新するため、次にしきい値を割ったときにあらためて通知が届きます。この記事で「再武装」と呼んだ処理です。
壁2(ロケーション)については、しきい値・判定・通知・一覧の絞り込みまで、すべてがロケーション軸を持っています。商品別しきい値の編集モーダルには「対象ロケーション」のセレクトがあり、「全ロケーション共通」か特定のロケーションかを選べます。設定画面の「対象ロケーション」でチェックを外したロケーションは通知の対象外になります。ロケーションが 1 つしかないストアでは、この設定は表示されません。
壁3(取りこぼし)については、定期スキャンが標準で動きます。加えて、在庫一覧画面に「今すぐスキャン」のボタンと「最終同期」の時刻表示があります。インストール直後は在庫データがまだ無いので、一度スキャンを実行して一覧に反映させるのが最初の一歩です。連続実行を防ぐため、前回から 5 分以内は実行できません。コレクションで絞り込む場合、商品数が非常に多いコレクションでは先頭 1,000 商品で絞り込まれ、その旨が画面に表示されます。
壁4(管理画面)については、必要な 3 画面がすべて用意されています。設定(既定しきい値・即時通知・定期レポート・通知先・対象ロケーション・言語設定)、在庫一覧、商品別しきい値です。商品の追加には Shopify の商品選択画面を使い、商品の中の特定のバリエーションだけを選ぶことも、商品ごと選んで配下の全バリエーションを対象にすることもできます。複数選んだ場合は一括設定モーダルが開き、選んだすべてに同じ設定を適用できます(1 回につき最大 50 バリエーション)。
入力値の検証も、この記事で触れた落とし穴を踏まえた作りになっています。しきい値の入力欄を空欄にすると「既定値を使う」の意味になり、0 として保存されることはありません。緊急しきい値は低在庫しきい値より小さくないと保存できず、片方だけ既定値に落ちて逆転するケースも保存時に弾かれます。
運用のコツも挙げておきます。インストール直後にやることは 3 つだけです。通知先を 1 件以上登録する、既定のしきい値を決める、「今すぐスキャン」を実行する。この 3 つで動き始めます。通知先が空のままだと、しきい値を割っても何も届かないので、ここは必ず最初に済ませてください。
しきい値の決め方は「リードタイム × 1 日あたりの販売数 × 安全係数」が基本です。発注から入荷まで 7 日、1 日平均 3 個売れる商品なら、7 × 3 = 21 個に安全係数 1.2〜1.5 を掛けて 25〜32 個。緊急しきい値はその半分を目安にします。最初から完璧な数字を出す必要はなく、まずストア既定値を 1 つ決めて動かし、通知が多すぎる商品・少なすぎる商品だけを個別に上書きしていくのが現実的です。
商品点数が数千あるストアでは、「通知の対象」を「商品別しきい値を設定した商品だけ」に切り替えて、売れ筋 50 点だけを見張る運用が向きます。逆に商品点数がそれほど多くないストアは「すべての商品」にして、要らないものを「通知対象から除外する」で外していくほうが早く整います。
通知メールの文面を変えたら、必ず一度テスト送信してください。変数の書き間違いは、実際に届いたメールを見ないと気づけません。なお、通知メールと Slack の文面はストアの言語に従います。管理画面の言語(20 言語対応)とは別です。管理画面を英語で使っていても、ストアの言語が日本語なら通知は日本語で届きます。
価格設定

メール・Slack への低在庫アラートと在庫予測を備えた、軽量な在庫管理アプリ。
特徴・機能
この分野の直接的な競合です。無料プランは「1 ロケーション・1 ユーザー・1 つの定期通知」まで、Basic は「5 つの即時または定期通知・3 ユーザー」、Advanced は「通知・ユーザー・ロケーションが無制限」という構成になっています。シンプル低在庫アラートとの違いは、日本語対応としきい値の粒度、そして在庫予測の有無です。 Stockie は「この調子だと何日で切れるか」という予測を見られる一方、管理画面は英語です。予測まで見たいなら Stockie、日本語の画面で商品・バリエーション・ロケーション別のしきい値を細かく設定したいならシンプル低在庫アラート、という選び分けになります。
価格設定
TODO: 画像を入れる(TēPs のサービス紹介画像)
Shopify と各種チャットツールをつなぐ、ノーコードの業務自動化サービス。
特徴・機能
Shopify アプリではなく、外部の業務自動化サービスです。在庫通知に限らず、注文・顧客データを使ったさまざまな自動化を組めます。シンプル低在庫アラートが在庫通知だけに特化しているのに対し、TēPs は業務全体を自動化する汎用ツールです。 LINE や Chatwork へ通知を飛ばしたい、在庫以外の自動化もまとめたい、という場合は TēPs が候補になります。逆に、在庫通知だけを最短で整えたいなら専用アプリのほうが設定は速く済みます。
価格設定

トリガー・条件・アクションの組み合わせで Shopify の運用を自動化する公式アプリ。
特徴・機能
Shopify が無料で提供する公式アプリで、日本語の管理画面があります。単一ロケーション・少数商品なら、これで十分に足ります。 一方で、しきい値を商品ごとに変えるにはワークフローを商品の数だけ作ることになり、重複通知を止めるには状態の記憶をメタフィールドなどで自作する必要があります。シンプル低在庫アラートは、この 2 つを設定だけで越えるためのアプリです。 在庫通知は専用アプリ、それ以外の自動化は Flow、という分担が自然な形になります。
価格設定

顧客向けの入荷待ちリストと、ストア向けの低在庫アラートを備えたアプリ。
特徴・機能
もともと顧客向けの入荷待ちリストが主機能で、そこにストア向けの低在庫通知が加わった構成です。シンプル低在庫アラートとの使い分けは、仕入先を巻き込むかどうかです。 Notify Me はベンダー(仕入先)への定期送信を想定した作りで、「毎週月曜に仕入先へ発注候補リストを自動送信する」といった運用に向きます。シンプル低在庫アラートも定期レポートを送れますが、宛先はメールと Slack で、社内向けの運用が主眼です。顧客向けの再入荷通知も同時に欲しいなら Notify Me、社内の発注判断に集中したいならシンプル低在庫アラート、という分かれ方です。
価格設定
Shopify の低在庫通知を自作すると、次の 4 つの壁にぶつかります。
初期構築で 3〜4 週間、そのあと保守が続きます。既製品では満たせない要件がある場合だけ、自作に価値があります。
月曜の朝に「金曜の夕方から売れ筋が欠品していた」と気づく代わりに、金曜の夕方に「残り 8 個です」という通知を受け取る。その差を作るのに、3〜4 週間の開発と月額 $8 のどちらを選ぶか、という問題です。