Skip to content

Engineering

How one accepted quote becomes exactly one Shopify draft order

By Jahangir Alam · September 30, 2026 · 13 min read

Last verified
Shopify API
2026-07
Audience
Developers and agencies building or reviewing a quote-to-draft-order integration on Shopify
Scope
Making draftOrderCreate idempotent when Shopify offers no idempotency key at API 2026-07: one record per conversion, stored input, a database claim with a stale window, one success transaction with a transactional outbox, idempotent orders/create handling, reconciliation, and why an unknown create is never auto-retried

At Shopify Admin API version 2026-07, draftOrderCreate takes no idempotency key. Some mutations do - inventoryAdjustQuantities gained an optional @idempotent key in 2026-01 and made it required in 2026-04, and its reference page says so - but the draft-order mutation documents nothing of the kind. Call it twice with the same input and you have two draft orders, two invoices, and a buyer who pays one and disputes the other.

So an app that turns accepted quotes into draft orders has to make the conversion idempotent itself. This post walks through how we built it in QuotWay: one record per conversion, the exact calculated input stored and re-sent, a database claim so only one worker ever calls Shopify, the result recorded in one transaction with its outbound event, an idempotent webhook handler for the order that comes back, and a sweep that recovers what the webhooks miss - but deliberately refuses to retry the one case it can't see into.

The failure modes around conversion - tax drift, price drift, stock - are covered in what breaks between quote time and conversion time. This post is only about one of them: making sure it happens exactly once.

Shopify facts were checked against Shopify's reference pages on 30 September 2026 at API version 2026-07. The code is simplified from QuotWay's conversion services: names shortened, internal identifiers removed.

Where duplicates come from

Everything that can call draftOrderCreate twice eventually will:

  • The job queue retries. A create that times out from the caller's side may have succeeded on Shopify's. The queue sees a failure and tries again.
  • Two workers pick up the same job. At-least-once queues deliver a job twice under load or during a deploy.
  • A person clicks twice. "Convert to draft order" on a slow network.
  • Automation and a person both act. An auto-convert rule fires while a staff member converts by hand.
  • The process dies between Shopify's answer and your database write. The draft exists; your record says it doesn't.

The first four are races between attempts. The fifth is different: an attempt whose outcome you don't know. The design has to handle both, and they need different answers.

One record per conversion

Every conversion is a row, created before Shopify is called, unique per conversion group - the set of accepted lines that will become one draft order. A quote accepted in full has one group; a quote accepted in part and converted in two steps has two, and each becomes its own draft order. The row carries:

Field Why it's there
A key built from quote, group and version Unique; a second attempt finds the first
The exact DraftOrderInput that was calculated Re-sent verbatim on every attempt
A claim timestamp Who is calling Shopify right now, and since when
The Shopify draft order id Once set, the conversion is done
The Shopify order id Set when the buyer pays
Error count and last error What a person sees when it's stuck
The life of a conversion record A state diagram. A conversion starts as Draft, becomes Calculated when the draft order input has been calculated and stored, then a worker claims it. From Claimed, a successful create moves it to Converted with the Shopify draft order id stored; a failed create returns it to Failed, releases the claim, and the queue retries from there. When the buyer pays, the orders/create webhook links the order and the conversion is Order linked. A dashed path shows the unknown case: if a claim goes stale with no draft order id, the process may have died after Shopify created the draft, so the sweep flags it for a person instead of retrying. Draft Calculatedinput stored claim Claimedone worker calls Shopify draft created Converteddraft id stored orders/create Order linkedbuyer paid create failed Failedclaim released queue retries Outcome unknownstale claim, no draft id: a person decides
Every path is idempotent except the dashed one, which is the case a machine cannot see into.

Step 1: calculate once, send the same input

Before anything is created, the conversion is calculated: the lines, prices, discount, shipping, terms and the buyer (as a purchasingEntity for a company-aware quote) are assembled into a DraftOrderInput, checked against Shopify with draftOrderCalculate, and the merchant reviews any drift. That input is stored on the conversion row - the exact object, not the ingredients to rebuild it.

Every attempt at the create sends that stored object. A retry that recomputed the input could produce a draft the merchant never approved - a catalog price that moved overnight, a tax figure that shifted - and that is a correctness bug even when it isn't a duplicate.

Step 2: short-circuit, then claim

The create runs as a queued job. Its first two moves decide whether it calls Shopify at all:

// 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);
}

Two things make this safe. The decision is made by the database, in one conditional statement, so two workers can't both read "unclaimed" and both proceed - one update matches, the other matches nothing. And the claim is a timestamp, not a lock: if the worker holding it dies, the claim goes stale after two minutes instead of blocking the conversion forever.

The loser doesn't fail. If the winner has already stored the draft order id, the loser returns it as a success; if not, it throws a specific "in progress" error and the queue tries again shortly, by which time it usually finds the finished result.

Step 3: call Shopify, record the result in one transaction

The worker that holds the claim sends the stored input to draftOrderCreate. If Shopify returns user errors or the call fails, the worker records the error, increments the error count, releases the claim so the retry doesn't wait two minutes, and marks the conversion failed. The queue retries a small number of times with backoff; after that, a person sees the error.

If Shopify returns a draft order, everything that follows happens in one database transaction:

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

The outbox matters as much as the id. The quote.converted event that reaches webhooks and Shopify Flow is recorded in the same transaction as the state change, then dispatched after commit. An event sent before commit could announce a conversion that rolled back; an event sent after commit from memory is lost if the process dies in between. Written in the transaction, it exists exactly when the conversion does.

Step 4: the order comes back through a webhook

The draft order isn't the end. When the buyer pays, Shopify creates an order and sends orders/create - at least once, sometimes more than once, occasionally not at all, and with no guarantee about timing. The order carries a note attribute naming the conversion group, which is how the handler finds its way back.

Three layers keep it idempotent:

  1. Every webhook delivery is recorded, unique per shop and Shopify's webhook id. A redelivery is a duplicate insert, and the unique constraint turns a concurrent pair into one winner and one "already seen" - no check-then-insert race.
  2. The handler itself is idempotent. If the conversion already has an order id, a second receipt is a no-op.
  3. The webhook can beat our own write. A buyer who pays within seconds of receiving the invoice can trigger orders/create before the conversion row has committed. The handler doesn't treat a missing row as "not ours" - the note attribute says it is - so it schedules a delayed retry and tries again.
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);

Step 5: the sweep

A scheduled job runs every 15 minutes and closes the gaps that events can't:

Condition What the sweep does
Invoice sent more than 7 days ago, draft id stored, no order id Asks Shopify for the draft order. If it has an order, the buyer paid and the webhook was missed: replay the order through the same handler the webhook uses, so the catch-up path is the tested path
Calculated more than 24 hours ago, never created Reports it; the merchant decides - it may simply be waiting
Calculated, no draft id, unchanged for more than 5 minutes Flags it for a person. Does not retry.

Each branch is capped per run, and the Shopify lookups run a few at a time, so a backlog is deferred to the next run rather than blowing the job's time budget.

Why the sweep won't retry the unknown

The last row is the one custom builds skip. A conversion that was calculated, claimed and then went quiet with no draft id has two possible histories: the call never reached Shopify, or Shopify created the draft and the process died before recording it. From the database, they look identical. Without an idempotency key on draftOrderCreate, retrying the second history creates a second draft order - and a second invoice the buyer can pay.

So the sweep surfaces it instead. The resolution is a lookup, not a retry: search the store's draft orders for the conversion's reference (QuotWay's converted drafts carry quotway and a quote-reference tag, and the conversion-group note attribute), adopt the draft if it exists, and only then create one if it doesn't. That step needs a person today because it's rare and the cost of getting it wrong is money; if Shopify adds @idempotent to draftOrderCreate, the key goes on the call and this whole branch becomes a normal retry.

What happens if the process dies at each step

The process dies… State left behind What happens next
Before claiming Calculated, unclaimed The queue retries; the next attempt claims normally
After claiming, before calling Shopify Claimed, no draft id The claim goes stale after 2 minutes; the queue's retry re-claims and calls Shopify once
During the call, Shopify never received it Claimed, no draft id Same as above - but indistinguishable from the next row
After Shopify created the draft, before the transaction Claimed, no draft id, draft exists in Shopify The sweep flags it after 5 minutes; a person looks the draft up by its reference and adopts it
After the transaction, before the outbox dispatch Converted, event pending The worker's next run dispatches it - a 15-minute cron is the backstop, so a missed nudge delays the event, never drops it
While the order webhook is in flight Converted, no order id Shopify redelivers; or the 7-day sweep asks Shopify and replays the order

A checklist for your own build

  • One conversion record per future draft order, unique, created before any call to Shopify.
  • Store the exact calculated DraftOrderInput; re-send it, never recompute it.
  • Claim with a conditional update and a stale window - not an in-memory lock.
  • Release the claim on a clean failure, so retries don't wait.
  • Record the draft id, state changes and outbound events in one transaction; dispatch events after commit.
  • De-duplicate webhooks on Shopify's webhook id with a unique constraint, and make the handler idempotent as well.
  • Treat "webhook before my own write" as a retry, not as "not mine".
  • Reconcile by asking Shopify, and replay through the same handler.
  • Never auto-retry a create whose outcome you can't see; look the draft up first.
  • Check draftOrderCreate's reference page at every API release - if @idempotent appears, use it.

The same questions, put to a vendor instead of your own code, are in how to evaluate a Shopify B2B app, and the fields a converted draft order carries - tags, the note, the attributes an ERP can key on - are in ERP, CRM and PIM integration patterns.

Where this lives in QuotWay

Everything above is QuotWay's conversion path as it runs today, on every plan: an accepted quote, or each accepted part of one, becomes one Shopify draft order, and the quote.converted event fires once per draft order - to Shopify Flow on Professional and up, and to webhooks and the API on Enterprise. Plans are on the pricing page.

FAQ

Does Shopify's draftOrderCreate support idempotency keys?

Not at API version 2026-07: its reference page documents no @idempotent directive or idempotency argument. Mutations that support it say so on their reference page - inventoryAdjustQuantities, for example, made its key optional in 2026-01 and required in 2026-04. Check the draft-order page at each quarterly release.

How do I stop duplicate draft orders when a job retries?

Keep one record per conversion, created before the call, and let exactly one worker claim it with a conditional database update. A retry that finds a stored draft order id returns it; a retry that loses the claim waits and tries again. Re-send the exact input you calculated, so a retry can't create a different draft either.

What if my process crashes after Shopify created the draft order?

Your record shows a claim but no draft id, and you can't tell whether Shopify received the call. Don't retry blindly - that's the one path to a real duplicate. Search the store's draft orders for the reference you attached (a tag or note attribute), adopt the draft if it's there, and create one only if it isn't.

How do I handle duplicate orders/create webhooks?

Record each delivery with Shopify's webhook id under a unique constraint, so a redelivery is rejected as a duplicate even when two copies arrive at once. Then make the handler itself idempotent: if the order is already linked, do nothing.

What if the orders/create webhook arrives before my own database write?

It can, when a buyer pays within seconds of the invoice. If the order carries your reference but your record doesn't exist yet, schedule a delayed retry rather than discarding it as "not ours".

What if the orders/create webhook never arrives?

Webhooks aren't guaranteed. Reconcile on a schedule: for drafts whose invoice went out a while ago with no order recorded, ask Shopify whether the draft has an order, and if it does, feed it through the same handler the webhook uses.

Sources

Related articles

See how QuotWay handles this on your store.

We’d like to set analytics cookies to understand how the site is used. They’re not required — declining changes nothing about how the site works, and you can change your mind any time on our privacy page.