---
title: "承諾された見積1件を、Shopifyの下書き注文ちょうど1件にする方法"
description: "API 2026-07のdraftOrderCreateには冪等キーがありません。見積の変換を1回だけにする仕組み：確保、保存した入力、アウトボックス、Webhook、突合。"
url: "https://www.quotway.com/ja/blog/idempotent-draft-order-creation"
type: "blog post"
category: "Engineering"
published: "2026-09-30"
verified: "2026-09-30"
shopify_api_version: "2026-07"
audience: "Shopifyで見積から下書き注文への連携を開発・レビューする開発者とエージェンシー"
scope: "API 2026-07でShopifyが冪等キーを提供しないdraftOrderCreateを冪等にする方法。変換ごとに1つのレコード、保存した入力、期限切れの猶予を持つデータベース上の確保、トランザクショナル・アウトボックスを含む成功時の単一トランザクション、冪等なorders/createの処理、突合、そして結果が不明な作成を決して自動再試行しない理由"
locale: "ja"
source: "QuotWay - B2B Quote & Negotiation App for Shopify"
---

# 承諾された見積1件を、Shopifyの下書き注文ちょうど1件にする方法

Shopify Admin APIのバージョン2026-07では、`draftOrderCreate` は冪等キー（idempotency key）を受け取りません。受け取るミューテーションもあります。`inventoryAdjustQuantities` は2026-01で任意の `@idempotent` キーを追加し、2026-04で必須にしており、そのことがリファレンスページに明記されています。しかし下書き注文のミューテーションには、そうした記載が一切ありません。同じ入力で2回呼べば、下書き注文が2つ、請求書が2通、片方を支払ってもう片方に異議を唱えるバイヤーが1人できます。

つまり、承諾された見積を下書き注文に変換するアプリは、変換の冪等性（idempotency）を自分で確保しなければなりません。この記事では、QuotWayでそれをどう作ったかを順に説明します。変換ごとに1つのレコード、計算したとおりの入力を保存して再送すること、Shopifyを呼ぶワーカーを1つに限るデータベース上の確保、送信イベントと一緒に1つのトランザクションで記録する結果、戻ってくる注文のための冪等なWebhookハンドラー、そしてWebhookが取りこぼしたものを回収しつつ、中を見通せない1つのケースだけはあえて再試行しない突合ジョブです。

税のずれ、価格のずれ、在庫など、変換まわりの失敗パターンは[見積の時点と変換の時点の間で壊れるもの](/blog/quote-to-draft-order-failure-modes)で扱っています。この記事が扱うのはそのうちの1つだけ、変換を確実にちょうど1回だけ起こすことです。

Shopifyに関する事実は、2026年9月30日にAPIバージョン2026-07のShopifyリファレンスページと照合しました。コードはQuotWayの変換サービスを簡略化したもので、名前を短くし、内部の識別子を取り除いています。

## 重複はどこから生まれるか
`draftOrderCreate` を2回呼びうるものは、いずれ必ず2回呼びます。

- **ジョブキューの再試行。** 呼び出し側でタイムアウトした作成が、Shopify側では成功していることがあります。キューは失敗と見なして、もう一度試します。
- **2つのワーカーが同じジョブを拾う。** 少なくとも1回（at-least-once）配信のキューは、高負荷時やデプロイ中に同じジョブを2回配信します。
- **人が2回クリックする。** 遅いネットワークでの「下書き注文に変換」ボタン。
- **自動化と人が同時に動く。** スタッフが手動で変換している最中に、自動変換のルールが発火します。
- **Shopifyの応答とデータベースへの書き込みの間でプロセスが停止する。** 下書きは存在するのに、自分のレコードには存在しないと書かれています。

最初の4つは試行どうしの競合です。5つ目は性質が違い、結果が分からない試行です。設計は両方に対処しなければならず、それぞれに別の答えが必要です。

## 変換ごとに1つのレコード
変換はそれぞれ1行のレコードで、Shopifyを呼ぶ前に作成され、*変換グループ*ごとに一意です。変換グループとは、1つの下書き注文になる承諾済みの明細の集まりです。全体が承諾された見積には1つのグループがあり、一部だけ承諾されて2回に分けて変換される見積には2つあり、それぞれが別の下書き注文になります。行が持つものは次のとおりです。

| 項目 | 存在する理由 |
| --- | --- |
| 見積、グループ、バージョンから作るキー | 一意。2回目の試行が1回目を見つけられる |
| 計算したとおりの `DraftOrderInput` | 毎回の試行でそのまま再送する |
| 確保のタイムスタンプ | 誰が今Shopifyを呼んでいて、それがいつからか |
| Shopifyの下書き注文ID | これが入れば変換は完了 |
| Shopifyの注文ID | バイヤーが支払ったときに入る |
| エラー回数と最後のエラー | 行き詰まったときに人が見るもの |

> **変換レコードのライフサイクル**
>
> 状態遷移図。変換は「未計算」から始まり、下書き注文の入力が計算・保存されると「計算済み」になり、続いてワーカーがそれを確保します。「確保済み」から、作成に成功すると、Shopifyの下書き注文IDを保存して「変換済み」に進みます。作成に失敗すると「失敗」になり、確保を解放し、そこからキューが再試行します。バイヤーが支払うと、orders/create Webhookが注文を紐づけ、変換は「注文を紐づけ済み」になります。破線の経路は結果が不明なケースを示します。下書き注文IDがないまま確保が古くなった場合、Shopifyが下書きを作成した後にプロセスが停止した可能性があるため、突合ジョブは再試行せず、人が判断するよう知らせます。
>
> 破線を除くすべての経路は冪等です。破線は、機械からは中を見通せないケースです。

## ステップ1：計算は1回、送る入力は同じ
何かを作成する前に、変換を*計算*します。明細、価格、割引、送料、支払条件、そしてバイヤー（会社を扱う見積では `purchasingEntity` として）を `DraftOrderInput` にまとめ、`draftOrderCalculate` でShopifyと照合し、ずれがあればマーチャントが確認します。この入力を変換の行に保存します。保存するのはオブジェクトそのものであって、組み立て直すための材料ではありません。

作成の試行は毎回、この保存済みのオブジェクトを送ります。再試行のたびに入力を*計算し直す*と、マーチャントが承認していない下書きができかねません。一晩で動いたカタログ価格や、変わった税額です。それは重複でなくても、正しさのバグです。

## ステップ2：まず早期リターン、それから確保
作成はキューのジョブとして実行されます。最初の2つの手で、そもそもShopifyを呼ぶかどうかが決まります。

```ts
// 1. Already done? A retry or a double-click lands here.
if (conversion.draftOrderId) return { conversion, isExisting: true };

// 2. Claim it. Exactly one worker's conditional update matches.
const CLAIM_STALE_MS = 2 * 60 * 1000;
const claimedAt = new Date();
const claim = await db.conversion.updateMany({
  where: {
    id: conversion.id,
    draftOrderId: null,
    OR: [
      { claimedAt: null },
      { claimedAt: { lt: new Date(claimedAt.getTime() - CLAIM_STALE_MS) } },
    ],
  },
  data: { claimedAt },
});

if (claim.count === 0) {
  // Lost the race. If the winner already stored the draft, that's our answer;
  // otherwise it's still mid-call - throw, and the queue retries in a moment.
  const fresh = await db.conversion.findUnique({ where: { id: conversion.id } });
  if (fresh?.draftOrderId) return { conversion: fresh, isExisting: true };
  throw new ConversionInProgressError(conversion.id);
}
```

これを安全にしている点は2つあります。判断はデータベースが1つの条件付き文で下すので、2つのワーカーがどちらも「未確保」と読んで先へ進むことはありません。一方の更新が一致し、もう一方は何にも一致しません。そして確保はロックではなくタイムスタンプです。確保を持ったワーカーが停止しても、確保は2分で古くなり、変換を永遠にふさぐことはありません。

競合に負けた側は失敗しません。勝った側がすでに下書き注文IDを保存していれば、負けた側はそれを成功として返します。まだなら、専用の「進行中」エラーを投げ、キューがすぐに再試行します。その頃には、たいてい完了した結果が見つかります。

## ステップ3：Shopifyを呼び、結果を1つのトランザクションで記録する
確保を持つワーカーが、保存済みの入力を `draftOrderCreate` に送ります。Shopifyがユーザーエラーを返すか呼び出しが失敗した場合、ワーカーはエラーを記録してエラー回数を増やし、再試行が2分待たずに済むよう**確保を解放**し、変換を失敗としてマークします。キューはバックオフを挟んで少ない回数だけ再試行し、それでもだめならエラーが人に示されます。

Shopifyが下書き注文を返した場合、その後の処理はすべて1つのデータベーストランザクションの中で行います。

```ts
await db.$transaction(async (tx) => {
  await tx.conversion.update({
    where: { id: conversion.id },
    data: { draftOrderId: draft.id, draftOrderName: draft.name, invoiceUrl: draft.invoiceUrl, lastError: null },
  });
  await tx.conversionGroup.update({ where: { id: group.id }, data: { state: "CONVERTED" } });
  await advanceQuoteState(tx, quote);            // partially -> fully converted when the last group lands

  // The outbound event is written in the SAME transaction (a transactional outbox).
  await tx.integrationEvent.create({
    data: { quoteId: quote.id, type: "quote.converted", data: { draftOrderId: draft.id } },
  });
});
await dispatchIntegrationEvents(); // after commit, best effort; a 15-minute cron sends anything still pending
```

トランザクショナル・アウトボックス（transactional outbox）は、IDと同じくらい重要です。WebhookとShopify Flowに届く `quote.converted` イベントは、状態の変更と同じトランザクションで記録し、コミット後に送信します。コミット*前*に送ったイベントは、ロールバックされた変換を知らせてしまうかもしれません。コミット*後*にメモリから送るイベントは、その間にプロセスが停止すれば失われます。トランザクションの中で書けば、イベントは変換が存在するときにだけ、ちょうど存在します。

## ステップ4：注文はWebhookで戻ってくる
下書き注文で終わりではありません。バイヤーが支払うと、Shopifyは注文を作成して `orders/create` を送ります。少なくとも1回、ときに2回以上、まれに一度も届かず、タイミングの保証もありません。注文には変換グループを示すノート属性が付いており、ハンドラーはそれを手がかりに元の変換へたどり着きます。

冪等性は3つの層で守ります。

1. **Webhookの配信をすべて、ショップとShopifyのWebhook IDごとに一意で記録する。** 再配信は重複した挿入になり、一意制約によって同時に届いた2つは勝者1つと「受信済み」1つに分かれます。確認してから挿入する方式の競合は起きません。
2. **ハンドラー自体が冪等である。** 変換にすでに注文IDがあれば、2回目の受信は何もしません。
3. **Webhookが自分の書き込みより先に届くことがある。** 請求書を受け取って数秒で支払うバイヤーは、変換の行がコミットされる前に `orders/create` を発生させることがあります。ハンドラーは行がないことを「自分のものではない」とは扱いません。ノート属性が自分のものだと示しているからです。そこで遅延再試行を予約し、もう一度試します。

```ts
const groupId = readNoteAttribute(order.note_attributes, "conversion_group_id");
if (!groupId) return { kind: "not-ours" };

const conversion = await db.conversion.findUnique({ where: { groupId } });
if (!conversion) {
  await enqueueDelayedRetry({ order, shopId });   // our own write hasn't landed yet
  return { kind: "retry-later" };
}
if (conversion.orderId) return { kind: "already-linked" }; // Shopify redelivered

await linkOrderAndAdvanceQuote(db, conversion, order);
```

## ステップ5：突合ジョブ
15分ごとに動く定期ジョブが、イベントでは埋められない隙間を埋めます。

| 条件 | 突合ジョブがすること |
| --- | --- |
| 請求書の送信から7日超、下書きIDあり、注文IDなし | Shopifyに下書き注文を問い合わせる。注文があれば、バイヤーは支払っていてWebhookを取りこぼしている。その注文をWebhookと**同じハンドラーで**再生し、追いつくための経路をテスト済みの経路と同じにする |
| 計算から24時間超、作成されていない | 報告する。判断はマーチャントに任せる。単に待っているだけかもしれない |
| 計算済み、下書きIDなし、5分超変化なし | **人に知らせる。再試行はしない。** |

どの分岐も1回の実行あたりの件数に上限があり、Shopifyへの問い合わせは数件ずつ実行するので、滞留分はジョブの時間予算を超えずに次の実行へ持ち越されます。

## 突合ジョブが結果不明のものを再試行しない理由
最後の行こそ、独自開発が飛ばしがちなものです。計算され、確保され、その後下書きIDがないまま動かなくなった変換には、2通りの経緯がありえます。呼び出しがShopifyに届かなかったか、Shopifyが下書きを作成した後、記録する前にプロセスが停止したかです。データベースから見ると、この2つは区別できません。`draftOrderCreate` に冪等キーがない以上、後者の経緯で再試行すれば2つ目の下書き注文ができ、バイヤーが支払える2通目の請求書ができます。

そこで突合ジョブは、これを再試行せずに表面化させます。解決するのは再試行ではなく検索です。ストアの下書き注文から変換の参照を探し（QuotWayが変換した下書きには `quotway` タグと見積参照のタグ、そして変換グループのノート属性が付いています）、下書きがあればそれを採用し、なければそのときに初めて作成します。この手順に今は人が必要なのは、まれにしか起きず、判断を誤ったときの代償がお金だからです。Shopifyが `draftOrderCreate` に `@idempotent` を追加すれば、キーを呼び出しに付けるだけで、この分岐はまるごと通常の再試行になります。

## 各ステップでプロセスが停止したら
| プロセスが停止するタイミング | 残る状態 | 次に起きること |
| --- | --- | --- |
| 確保の前 | 計算済み、未確保 | キューが再試行し、次の試行が通常どおり確保する |
| 確保の後、Shopifyを呼ぶ前 | 確保済み、下書きIDなし | 確保は2分で古くなり、キューの再試行が確保し直してShopifyを1回呼ぶ |
| 呼び出しの途中で、Shopifyが受け取っていない | 確保済み、下書きIDなし | 上と同じ。ただし次の行と区別できない |
| Shopifyが下書きを作成した後、トランザクションの前 | 確保済み、下書きIDなし、**Shopifyに下書きが存在する** | 突合ジョブが5分後に知らせ、人が参照で下書きを探して採用する |
| トランザクションの後、アウトボックスの送信前 | 変換済み、イベントは保留中 | ワーカーの次の実行が送信する。15分ごとのcronが最後の備えなので、送信のきっかけを逃してもイベントは遅れるだけで、失われない |
| 注文のWebhookが届く途中 | 変換済み、注文IDなし | Shopifyが再配信する。または7日後の突合ジョブがShopifyに問い合わせて注文を再生する |

## 自分で作るときのチェックリスト
- 将来の下書き注文1件につき変換レコードを1つ、一意に、Shopifyを呼ぶ前に作成する。
- 計算したとおりの `DraftOrderInput` を保存し、再送する。決して計算し直さない。
- 確保は条件付き更新と期限切れの猶予で行う。メモリ上のロックは使わない。
- 正常に失敗したときは確保を解放し、再試行が待たずに済むようにする。
- 下書きID、状態の変更、送信イベントを1つのトランザクションで記録し、イベントはコミット後に送信する。
- WebhookはShopifyのWebhook IDの一意制約で重複を除き、さらにハンドラー自体も冪等にする。
- 「自分の書き込みより先に届いたWebhook」は「自分のものではない」ではなく、再試行として扱う。
- 突合はShopifyに問い合わせて行い、同じハンドラーで再生する。
- 結果が見えない作成は決して自動で再試行しない。まず下書きを探す。
- APIのリリースごとに `draftOrderCreate` のリファレンスページを確認する。`@idempotent` が現れたら使う。

同じ問いを自社のコードではなくベンダーに向けたものは[Shopify B2Bアプリの評価方法](/blog/shopify-b2b-app-evaluation-checklist)に、変換された下書き注文が持つ項目（タグ、メモ、基幹システムがキーにできる属性）は[基幹システム・CRM・PIMの連携パターン](/blog/shopify-b2b-erp-crm-pim-integration-patterns)にあります。

## QuotWayでの位置づけ
ここまでの内容はすべて、QuotWayの変換経路が現在すべてのプランで実際に動いているとおりのものです。承諾された見積、または承諾されたその一部のそれぞれが、Shopifyの[下書き注文](/features/convert-to-orders)1件になり、`quote.converted` イベントは下書き注文ごとに1回発火します。Shopify FlowへはProfessional以上で、[WebhookとAPI](/features/api-webhooks)へはEnterpriseで届きます。プランは[料金ページ](/pricing)をご覧ください。

## よくある質問

### ShopifyのdraftOrderCreateは冪等キーに対応していますか？

API 2026-07の時点では対応していません。リファレンスページには `@idempotent` ディレクティブも冪等性の引数も記載されていません。対応するミューテーションはリファレンスページにそう明記しています。たとえば `inventoryAdjustQuantities` は、2026-01でキーを任意にし、2026-04で必須にしました。四半期ごとのリリースのたびに、下書き注文のページを確認してください。

### ジョブが再試行したときに下書き注文の重複を防ぐには？

変換ごとに1つのレコードを呼び出しの前に作成し、条件付きのデータベース更新で1つのワーカーだけがそれを確保できるようにします。保存済みの下書き注文IDを見つけた再試行はそれを返し、確保の競合に負けた再試行は待ってからやり直します。計算したとおりの入力を再送すれば、再試行が別の内容の下書きを作ることもありません。

### Shopifyが下書き注文を作成した後にプロセスがクラッシュしたら？

レコードには確保があるのに下書きIDがなく、Shopifyが呼び出しを受け取ったかどうかは分かりません。やみくもに再試行してはいけません。それが本当の重複につながる唯一の経路です。付けておいた参照（タグやノート属性）でストアの下書き注文を探し、あれば採用し、なければそのときに初めて作成します。

### 重複したorders/create Webhookはどう扱えばよいですか？

配信ごとにShopifyのWebhook IDを一意制約のもとで記録し、2通が同時に届いた場合でも再配信が重複として拒否されるようにします。そのうえでハンドラー自体を冪等にし、注文がすでに紐づいていれば何もしません。

### orders/create Webhookが自分のデータベース書き込みより先に届いたら？

バイヤーが請求書を受け取って数秒で支払うと、起こりえます。注文に自分の参照が付いているのにレコードがまだ存在しなければ、「自分のものではない」として捨てるのではなく、遅延再試行を予約します。

### orders/create Webhookが届かなかったら？

Webhookは保証されていません。定期的に突合します。請求書を送ってしばらく経つのに注文が記録されていない下書きについて、下書きに注文があるかShopifyに問い合わせ、あればWebhookと同じハンドラーに通します。

## 出典

- [draftOrderCreate（2026-07）](https://shopify.dev/docs/api/admin-graphql/2026-07/mutations/draftOrderCreate)、[冪等なリクエスト](https://shopify.dev/docs/api/usage/idempotent-requests)、[inventoryAdjustQuantities（2026-07）](https://shopify.dev/docs/api/admin-graphql/2026-07/mutations/inventoryAdjustQuantities)。2026年9月30日に確認
- ShopifyのWebhook配信（タイムアウト、再試行、重複、Webhook ID）。[見積の時点と変換の時点の間で壊れるもの](/blog/quote-to-draft-order-failure-modes#sources)での整理による。2026年9月20日〜22日に確認
- QuotWayの変換、Webhook、突合の各サービス。2026年9月30日に確認。上記のコードはこれらを簡略化したもの
