LUCENTCOMMERCEGET A FREE STORE AUDITFREE AUDIT

TECHNICAL · INTEGRATIONS · APPS · 26 MAY 2026 · 8 MIN READ

Rate-limited, retried and idempotent: integration fundamentals

Three properties that only work together. Without idempotency you cannot retry safely; without retries you cannot survive a rate limit; without respecting the limit your retries are the outage.

A request and its response, side by side

A reliable Shopify integration is built from three properties that depend on each other in a fixed order. Make every write idempotent, so that performing it twice leaves the store in the same state as performing it once. Then retry, because the network, the throttle and the occasional 5xx guarantee you will need to — and retrying is only safe once the first property holds. Then make the retries respect the rate limit, because a retry loop that ignores the leaky bucket does not recover from throttling, it sustains it. Most integrations that are described as unreliable have all three missing, and fixing them in any other order produces duplicate orders instead of a working system.

IN SHORT

  • Idempotency is the prerequisite, not the polish: until a write is safe to repeat, every retry you add is a duplicate waiting to happen.
  • Shopify gives you two documented idempotency primitives — `productSet` upserts by an `identifier` that can be a `customId` holding your external system's key, and `inventorySetQuantities` takes a `compareQuantity` so the write only lands if the persisted value still matches.
  • Shopify's documentation tells you to "ignore duplicate deliveries using `X-Shopify-Webhook-Id`" — inbound idempotency is a header and a dedup table, not an architecture.
  • Never retry a `userErrors` response. A mutation rejected on business logic will be rejected identically forever; retrying it just spends your rate limit to fail more often.
  • GraphQL Admin restores 100 points per second on standard plans, 200 on Advanced, 1,000 on Plus and 2,000 on enterprise, and no single query may exceed 1,000 points on any plan.
  • Shopify states the recommended backoff time is one second — add jitter yourself, because synchronised retries from your own workers are a self-inflicted thundering herd.
  • From API version 2026-01 an app can run up to five bulk mutation operations per shop simultaneously, where earlier versions allowed one of each type — and the `bulkOperationRunMutation` request itself is not rate limited.
  • Webhooks are a notification system, not a source of truth: Shopify says apps "shouldn't rely on receiving data from Shopify webhooks" and recommends a reconciliation job.

Three properties, one design

These get treated as three items on a checklist, and they are not. They are a chain, and the order is not negotiable.

Start at the end and it is obvious why. Suppose you add retries to an integration that pushes orders into an ERP. The network drops a response after the ERP has already committed the write. Your client sees a timeout, retries, and now there are two orders. You have not made the integration more reliable — you have converted a rare visible failure into a frequent invisible one, which is strictly worse, because nobody gets paged for a duplicate until finance finds it three weeks later.

Now suppose you make the writes idempotent but do not think about the rate limit. Your retry loop hits a throttle, retries immediately, gets throttled again, and retries faster than the bucket refills. The integration is now generating the load that is stopping it from working, and it will stay in that state until somebody kills the process. Backoff is not politeness towards Shopify; it is the only way out of the hole.

So: idempotency, then retries, then rate-limit awareness. Each one is what makes the next one safe.

Idempotency, and the two primitives Shopify actually gives you

Idempotent means a write can be performed more than once without changing the outcome. The usual way to get there is a key: some value that identifies *this specific intended change*, so the second attempt can be recognised as the same change rather than a new one.

Shopify's Admin API does not hand you a general-purpose idempotency-key header to stamp on any mutation. What it gives you instead — and this is better for the cases it covers — are two documented mechanisms that build the property into the mutation itself.

Upsert by your own identifier. The productSet mutation takes an identifier argument, documented as specifying "the identifier that will be used to lookup the resource". One of the accepted identifiers is customId, typed as a UniqueMetafieldValueInput and described as the "Custom ID of product to upsert". That is the hook: put your ERP or PIM's product key into a unique metafield, and every sync from then on is an upsert against a key you own rather than a create-or-update decision your code has to make correctly. Run the same sync twice and you get the same product, because the second run resolves to the record the first one created.

Be deliberate about what productSet does to omitted fields, because it is not uniform. For list-typed fields such as variants, collections and metafields, the documentation says it "creates new entries, updates existing entries, and deletes existing entries that aren't included in the mutation's input". For everything else, it "updates only the included fields. Any omitted fields will remain unchanged." A partial payload will therefore preserve a title it did not mention and delete a variant it did not mention. That is the correct behaviour for a full sync from a system of record and a trap for a partial update.

Compare-and-set on inventory. inventorySetQuantities accepts a compareQuantity, and the documentation states that "the mutation will only update the quantity if the persisted quantity matches the compareQuantity value", returning an error if it does not. There is an ignoreCompareQuantity flag to opt out, and the documentation warns that opting out can compromise accuracy under concurrent requests.

That is worth pausing on, because it is doing something subtler than deduplication. Inventory is the one field where several systems write at once — the warehouse, the storefront, a returns tool, a stock-take. Compare-and-set means a stale write loses rather than wins. The failure mode it prevents is not a duplicate; it is your nightly sync silently reinstating a quantity that a sale corrected four minutes ago. Use ignoreCompareQuantity only where you genuinely are the sole authority, and be honest about how rarely that is true.

Inbound, the key is a header. For webhooks, Shopify's best-practices documentation says to "ignore duplicate deliveries using X-Shopify-Webhook-Id". Store the id, check it before processing, and keep the table for long enough to cover the retry window. This is a dozen lines of code and it eliminates an entire category of support ticket.

For everything not covered by those — creating a fulfilment, issuing a refund, writing a metafield — you construct the key yourself: a deterministic identifier derived from the source event, recorded on your side before the call and checked after a failure. The rule is that the key must come from the input, not from a clock or a random generator, or the retry will mint a new one and defeat the point.

Retries: the errors you must retry, and the ones you must not

Blanket retry logic is almost as bad as none, because it turns permanent failures into permanent load. The useful distinction is whether the same request, sent again unchanged, could plausibly succeed.

  • Retry: connection failures, timeouts, and 5xx responses. The request may never have been processed, or may have been processed and lost on the way back — which is exactly the case idempotency exists for.
  • Retry, with backoff: a 429. The request was well-formed and you were over the limit. This is the one where the backoff interval, not the retry itself, is the whole design.
  • Never retry: a mutation that returned userErrors. The Admin API documents these as "an error in the input of a mutation… validation failures, such as invalid field values or business logic violations". The operation ran and the business logic rejected it. Sending it again produces the same rejection, at the cost of rate limit you needed for work that could succeed. Route these to a dead-letter queue a human reads.
  • Never retry, and alert: authentication and authorisation failures. An app whose token has been revoked or whose scopes have changed does not recover by trying harder, and a retry loop hides the fact that the integration has been dead since Tuesday.

Retrying into a rate limit

Shopify meters the GraphQL Admin API by calculated query cost, restoring 100 points per second on standard plans, 200 on Advanced Shopify, 1,000 on Shopify Plus and 2,000 on Shopify for enterprise, with a documented ceiling of 1,000 points for any single query regardless of plan. The API tells you where you stand on every response: extensions.cost.throttleStatus carries maximumAvailable, currentlyAvailable and restoreRate, alongside requestedQueryCost and actualQueryCost — and the documentation notes the difference between the requested and actual cost is refunded.

That changes what good behaviour looks like. You do not have to be throttled to find out you are close to it; every response already told you. An integration that reads currentlyAvailable and slows down before it hits zero never generates a 429 in the first place, which is a materially different system from one that treats 429 as a normal part of operation.

When you are throttled anyway, Shopify states that the recommended backoff time is one second. Add two things it does not give you. Exponential growth, so a sustained throttle does not become a one-second poll. And jitter, because if twenty workers are throttled by the same burst and all back off for exactly one second, they resume simultaneously and throttle each other again — a herd you built yourself.

The structural fix underneath all of this is one queue per shop with a concurrency limit. The bucket is shared across every job your app runs against that store, so the nightly catalogue sync and the customer-facing stock lookup are drawing from the same budget whether or not your code knows it. A single queue is how the budget becomes visible to both.

When the answer is not a better retry loop

Some work does not belong in the request-by-request lane at all. If you are updating ten thousand products, no amount of backoff tuning makes that a good use of a leaky bucket.

Bulk operations exist for this. You stage a JSONL file with stagedUploadsCreate, hand it to bulkOperationRunMutation, and Shopify runs the mutation against each line asynchronously. The documentation states that the bulkOperationRunMutation request itself is not subject to the standard rate limits, and that from API version 2026-01 each app can run up to five bulk mutation operations per shop simultaneously — earlier versions allowed only one of each type at a time per shop.

The property that makes bulk usable in practice is per-line error handling: each mutation in the file "is validated and executed independently, with errors reported in the output file alongside successful results", and the operation only fails outright on critical system errors. So a bad row does not abort the run — but it also does not retry itself. You download the results, filter the failures, and decide what to do with them. That is a job somebody has to write; bulk moves the work, it does not remove it.

And bulk is not idempotent on its own. Running the same file twice is only safe if the mutation inside it is — which is exactly why productSet with a customId is the right building block for a catalogue sync, and why a bulk file of blind productCreate calls is a way to duplicate your catalogue at speed.

The fourth property nobody budgets for

Idempotency, retries and rate-limit awareness get you a client that behaves. They do not get you a system that is correct, because they only cover the messages you received.

Shopify is unusually direct about this. The webhook documentation states that an app "shouldn't rely on receiving data from Shopify webhooks" because "webhook delivery isn't always guaranteed", and recommends reconciliation jobs that "periodically fetch data from Shopify so that your app stays consistent with Shopify's data", using updated_at filters to pick up anything changed since the last run.

It is equally direct about ordering: Shopify "doesn't guarantee ordering within a topic, or across different topics for the same resource", and gives the example of a products/update arriving before the products/create it depends on. The mitigation is to use the timestamps you are given — the X-Shopify-Triggered-At header, or updated_at in the payload — and discard an event older than the state you already hold. An integration that applies events in arrival order is not processing a stream, it is shuffling one.

Reconciliation is the least glamorous item on this list and the one that catches everything the other three miss. It is also, reliably, the first thing cut from a fixed-price integration quote, which is why so many integrations are discovered to be wrong by an accountant rather than by a monitor.

What we look at first on an integration we have inherited

Six questions. They are answerable in an afternoon and they usually locate the problem before anyone opens a log.

  • What is the idempotency key for each write, and where is it stored? "We check whether it exists first" is not a key — it is a race condition with extra steps.
  • Does the webhook endpoint dedup on X-Shopify-Webhook-Id, and does it discard events older than the state it holds?
  • Is there a retry policy that distinguishes userErrors from 5xx, and where do the non-retryable failures go?
  • Does anything read throttleStatus, or does the integration only discover the limit by hitting it?
  • How many processes write to this store, and do they share a queue? If the answer is "three services and no", the throttling is not mysterious.
  • Is there a reconciliation job, when did it last run, and what did it find? A reconciliation job that has never found a discrepancy is usually a reconciliation job that is not comparing anything.

The honest position

None of this is advanced. It is four well-understood properties, all of them documented, none requiring a framework or a platform. What makes them rare is that every one of them costs time on a project where the demo already works — the happy path is done in a fortnight, and idempotency, backoff, dead-letter handling and reconciliation are the fortnight after that, spent on failures nobody has seen yet.

That second fortnight is the integration. The first one is a prototype that happens to be in production. If you are choosing what to cut from a scope, cut a feature; the reliability work is what determines whether the features are true.

Questions this raises

What does idempotent mean for a Shopify integration?

It means a write can be sent twice without changing the result. In practice that comes from a key derived from the input — your own product identifier in a unique metafield, the `X-Shopify-Webhook-Id` on an inbound event, a deterministic reference on a fulfilment. A key generated at send time, from a clock or a random source, is not a key, because the retry generates a different one.

Does Shopify support idempotency keys on the Admin API?

Not as a general header you can attach to any mutation. It provides the property through specific mutations instead: `productSet` upserts against an `identifier` that can be a `customId` holding your external key, and `inventorySetQuantities` offers a `compareQuantity` compare-and-set. For anything outside that, you build the key and the dedup check yourself.

How should I back off when Shopify returns a 429?

Shopify states the recommended backoff time is one second. Grow it exponentially on repeated throttles and add jitter, so that multiple workers throttled by the same burst do not resume in lockstep. Better still, read `extensions.cost.throttleStatus` on every response and slow down before `currentlyAvailable` reaches zero — the information to avoid the 429 is in the response you already have.

Should I retry a mutation that returned userErrors?

No. The Admin API documents `userErrors` as validation failures and business logic violations in the input of a mutation — the operation ran and was rejected on its merits. The identical request will be rejected identically. Send it to a dead-letter queue for a human, and keep your rate limit for requests that can succeed.

Do bulk operations avoid rate limits?

Largely, for the work itself. Shopify documents that the `bulkOperationRunMutation` request is not subject to the standard rate limits, and from API version 2026-01 an app can run up to five bulk mutation operations per shop simultaneously, against one of each type in earlier versions. What bulk does not give you is idempotency or automatic retries — errors are reported per line in the output file, and acting on them is your job.

If I handle webhooks properly, do I still need a reconciliation job?

Yes, and Shopify says so. Its documentation states that apps should not rely on receiving webhook data because delivery is not always guaranteed, and recommends periodically fetching from the API using `updated_at` filters. Webhooks make your data fresh; reconciliation makes it correct. They are answering different questions and you need both.

NEXT STEP

Free store audit

A senior Shopify engineer reviews your storefront, theme performance and checkout, then sends a prioritised list of fixes.