DEV Community

Sike Ren
Sike Ren

Posted on

Shopify's draftOrderCreate has no idempotency key, so a timeout is not a failure

A wholesale buyer sends a purchase order. Someone at the store turns it into a Shopify draft order. If that happens twice, the buyer gets invoiced twice, or the warehouse picks the same pallet twice, and nobody notices until the credit note.

I build a Shopify app that does that conversion, so "one purchase order becomes exactly one draft" is the property that matters most. It turned out to be the one Shopify's API does least to help with.

The constraint: there is no idempotency key

Payment APIs taught a generation of us to send an Idempotency-Key header and retry freely. Shopify's draftOrderCreate mutation has nothing like it. Send the same input twice and you get two drafts.

That by itself is manageable. The part that shapes everything is this: a transport failure tells you nothing about whether the mutation ran. A timeout, a reset connection, a 502 from something in between — Shopify may have created the draft and you never saw the response.

So there are three honest outcomes for a create call, not two:

  • Created — you have the response and the draft's GID.
  • Rejected — you have the response and it says no (userErrors, or GraphQL errors). Note that HTTP 200 is not success here; you have to read both.
  • Uncertain — you don't have a response. The draft may or may not exist.

The obvious code treats uncertain as rejected and retries. That retry is exactly the duplicate you were trying to avoid.

Consequence 1: the lock lives in Postgres, not in the API

Before any request goes out, the app writes a row to a draft_creation_operation table: which document, an idempotency key derived from the document and its approved revision, and a random correlation token. Two constraints do the real work:

-- At most one operation per (shop, idempotency key), ever.
CREATE UNIQUE INDEX ON draft_creation_operation ("shopId", "idempotencyKey");

-- At most one operation per document that is still in play.
CREATE UNIQUE INDEX draft_creation_operation_one_active_per_document
  ON draft_creation_operation ("shopId", "documentId")
  WHERE status IN ('PENDING', 'IN_FLIGHT', 'UNCERTAIN');
Enter fullscreen mode Exit fullscreen mode

The partial index is the one I'd miss most. A double-click, two browser tabs, a queue redelivery and a worker that restarted mid-job all end up as a second INSERT that the database refuses. A disabled button is a UX nicety; it is not the guarantee.

Consequence 2: an uncertain call is reconciled by reading, never by retrying

When a call ends without a response, the operation goes to UNCERTAIN and blocks. Nothing retries it. A reconciliation job instead asks Shopify: does a draft carrying my correlation token exist?

That only works if you can find the draft again without its GID — which is the thing you didn't receive. So the correlation token is written onto the draft in three independent places:

const input = {
  lineItems,
  // 1. a tag — the one thing draftOrders search can filter on
  tags: [`orderproof:op:${correlationToken}`],
  // 2. a custom attribute — to confirm a search hit
  customAttributes: [{ key: 'orderproof_operation', value: correlationToken }],
  // 3. a metafield — readable when you already know the GID
  metafields: [{ namespace, key: 'correlation', type: 'single_line_text_field', value: correlationToken }],
};
Enter fullscreen mode Exit fullscreen mode

A small thing the docs don't make obvious: Shopify rejects a tag longer than 40 characters. The very first real draft this app tried to create failed with Title Tag exceeds the maximum length of 40 characters, because the token was 16 random bytes. It's 10 now — orderproof:op: plus op_ plus 20 hex characters is 37 — which is plenty for a marker that isn't a credential and is also unique in the database.

Reconciliation has three answers, and only one of them moves anything forward:

  • Found — record the draft; the operation is done.
  • Inconclusive (the read itself failed) — try the read again later.
  • Not found — and here's the trade-off I chose: not found is not proof of absence. Search results can lag, and a draft can be deleted by a person. So "not found" does not re-open the create. After enough attempts, the document goes to manual resolution and a human looks.

That means some orders need a person to click through. I accept that; a stuck order that says why is recoverable, a duplicate order that says nothing is not.

The bug: a search that matched nothing, silently

The tag I chose was orderproof:op:<token>. The reconciliation query was, reasonably:

query: `tag:orderproof:op:${token}`
Enter fullscreen mode Exit fullscreen mode

Every unit test passed. Then the first live run against a real development store produced a genuinely uncertain outcome — the draft had been created, but the app couldn't read the response back — and reconciliation answered not found for a draft I could see in the admin.

Shopify's search syntax reads : as the separator between a field and its value. tag:orderproof:op:abc parses as "tag is orderproof" followed by a term that means nothing, which matches no draft at all. No error, no warning — an empty list, which is precisely the answer this code path must never get wrong.

The fix is one pair of quotes:

query: `tag:"${expectedTag}"`
Enter fullscreen mode Exit fullscreen mode

and a comment above it explaining why, because the next person to "clean up" that string will otherwise remove them. The tests now use the quoted form against a fixture that contains colons, but the real lesson is the one I keep relearning: mocks agree with whatever you believed when you wrote them. The live run found it in minutes.

What this adds up to

None of this is clever. It's a table, two unique indexes, a token written three times, a state that refuses to move on its own, and a pair of quotes. What it buys is a sentence I can say to a merchant without hedging: approving a purchase order twice cannot give you two drafts.

If you're calling any Shopify mutation that has side effects outside your app — drafts, fulfilments, gift cards — it's worth asking the same question: what does your code do with the third outcome?

I build NeatPO, a Shopify app that turns wholesale purchase orders (PDF, CSV, Excel, email) into reviewed draft orders. Its App Store listing is in review; the review screen can be tried with sample data at neatpo.com/sample.

Top comments (0)