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.