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 |
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:
- 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.
- The handler itself is idempotent. If the conversion already has an order id, a second receipt is a no-op.
- The webhook can beat our own write. A buyer who pays within seconds of receiving the invoice can trigger
orders/createbefore 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@idempotentappears, 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
- draftOrderCreate (2026-07), idempotent requests and inventoryAdjustQuantities (2026-07), read 30 September 2026
- Shopify webhook delivery - timeouts, retries, duplicates and the webhook id - as summarised in what breaks between quote time and conversion time, read 20-22 September 2026
- QuotWay's conversion, webhook and reconciliation services, read 30 September 2026; the code above is simplified from them
Related articles
See how QuotWay handles this on your store.