Skip to content

Build1 publisher3 min readPublished

Retrying a timed-out Shopify draftOrderCreate call can invoice a wholesale buyer twice

Shopify's draftOrderCreate takes no idempotency key, so a call that times out may already have created the draft. One app developer parks those calls as uncertain and searches Shopify for a planted correlation token before anything gets created a second time.

The Engineer · Build desk

Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

What happened

  • Before each call the app inserts an operation row, and a partial unique index refuses a second pending, in-flight or uncertain operation for the same document.
  • The correlation token goes onto the draft as a tag for search, a custom attribute to confirm a hit, and a metafield readable once the GID is known.
  • On the first live uncertain outcome, the tag query returned not found for a draft the developer could see in the Shopify admin, after every unit test had passed.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • exposure Store tooling that retries a timed-out create can invoice a wholesale buyer twice or pick the same pallet twice, and the error surfaces only at the credit note.
  • constraint Only the tag is searchable, so its 40-character cap bounds the correlation token; this app's 37-character tag leaves 3 characters for any change to the prefix.
  • decision Any integrator reconciling by search has to decide what an empty result means, since treating it as absence reopens the duplicate path when search lags or a person deletes a draft.
  • cost Refusing duplicates costs a manual queue: some documents stall until a person resolves them, a cost the developer says is acceptable.

Payment APIs taught developers to send an Idempotency-Key header and retry freely, the developer wrote in a dev.to post [18]. That habit is wrong for this mutation. Send the same input twice and Shopify creates two drafts [1]. A timeout, a reset connection or a 502 from a proxy in the middle says nothing about whether the mutation ran [2].

So the post sorts every create call into three outcomes [3]. Created means you have a response and the draft's GID. Rejected means you have a response that says no. Uncertain means you have no response. HTTP 200 does not mean created: the rejection can arrive as userErrors or as GraphQL errors, and the client has to read both [3]. "The obvious code treats uncertain as rejected and retries. That retry is exactly the duplicate you were trying to avoid," the developer wrote [4].

The guarantee sits in the database. Before any request goes out, the app records the document, an idempotency key derived from the document and its approved revision, and a random correlation token [5]. This is the index I would copy first [6]:

```sql CREATE UNIQUE INDEX draft_creation_operation_one_active_per_document ON draft_creation_operation ("shopId", "documentId") WHERE status IN ('PENDING', 'IN_FLIGHT', 'UNCERTAIN'); ```

A double-click, two browser tabs, a queue redelivery and a worker that restarted mid-job all arrive as a second INSERT, and the database refuses it [7]. "A disabled button is a UX nicety; it is not the guarantee," the developer wrote [8]. A second unique index allows one operation per shop and key, ever [6]. The key includes the approved revision, so a revised purchase order gets a fresh key. The partial index still blocks it while an earlier attempt on that document is pending, in flight or uncertain [5].

Reconciliation has to find a draft without its GID, and the GID is what a timeout withholds [10]. Of the three places the token is written, the tag is the only one draft-order search can filter on [10]. Shopify caps tags at 40 characters. The app's first real draft failed on that cap with "Title Tag exceeds the maximum length of 40 characters" [11]. The token went from 16 random bytes to 10 [11][12]. The fixed prefix, orderproof:op: plus op_, takes 17 characters, and 20 hex characters bring the tag to 37, three under the cap [1]. Had the old token used the same hex encoding, the tag would have run to 49 characters [3]. Ten bytes is 80 bits [2]. According to the post, that is plenty for a marker that is not a credential and that the database already holds unique [12].

The choice I'd defend hardest is what "not found" means. Search results can lag and a person can delete a draft, so an empty search never reopens the create; after enough attempts the document goes to a human [13]. "I accept that; a stuck order that says why is recoverable, a duplicate order that says nothing is not," the developer wrote [14]. For purchase orders I think that is the right default. A duplicate lands on a buyer's invoice or a warehouse pick list and surfaces at the credit note [15]. A stuck order costs someone a click-through [20].

The first live run tested that rule. The reconciliation query was tag:orderproof:op:${token}, and every unit test passed [16]. Against a real development store, a draft was created, the response never came back, and the query answered not found for a draft visible in the admin [16]. If "not found" had reopened the create, that run would have made the duplicate; under the rule, the document goes to manual resolution [4]. The developer traces the miss to Shopify's search syntax, and the available text of the post stops before the corrected query [17].

What to watch

  • Whether Shopify adds an idempotency key or client request ID to draftOrderCreate, which would remove the need for token-based reconciliation.
  • The developer's corrected tag query and the exact search-syntax rule that made tag:orderproof:op:${token} miss a draft that existed.
  • How often reconciliation sends documents to manual resolution in production, since that queue is the running cost of the design.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories