DEV Community

Muhammad Masad Ashraf
Muhammad Masad Ashraf

Posted on • Originally published at kolachitech.com

Shopify Idempotency Strategies: A Developer's Survival Guide

You built a Shopify integration. It works great. Until it doesn't.

A customer's network drops right after the checkout POST fires. They refresh. The browser retries. Shopify receives two identical order creation requests. You now have a double order, a double charge, and a very confused customer.

This is an idempotency problem. And it is more common than most Shopify developers expect.

What Is Idempotency in a Shopify Context?
An operation is idempotent if running it once or a hundred times produces the same result. In Shopify integrations, this covers:

• API mutations (order creation, fulfillment, refunds)
• Webhook processing (order.paid, inventory.update)
• Background job retries
• Third-party sync operations

Strategy 1: Use Idempotency Keys on REST API Calls
Shopify's REST Admin API accepts an Idempotency-Key header on POST requests. Generate a UUID v4 per logical operation and send it with the request.

const { v4: uuidv4 } = require('uuid');  // Generate ONCE per business event, store it const idempotencyKey = uuidv4();  await fetch('https://your-store.myshopify.com/admin/api/2024-01/orders.json', {   method: 'POST',   headers: {     'Content-Type': 'application/json',     'X-Shopify-Access-Token': process.env.SHOPIFY_TOKEN,     'Idempotency-Key': idempotencyKey, // same key on every retry   },   body: JSON.stringify(orderPayload) });
Enter fullscreen mode Exit fullscreen mode

Key rule: Generate the key once. Store it before the request. Reuse it on every retry.

Strategy 2: Deduplicate Webhooks with X-Shopify-Webhook-Id
Shopify does not guarantee exactly-once webhook delivery. Every delivery includes an X-Shopify-Webhook-Id header. Use it as your deduplication key.

Express.js webhook handler

app.post('/webhooks/orders/paid', async (req, res) => {   const webhookId = req.headers['x-shopify-webhook-id'];

Enter fullscreen mode Exit fullscreen mode

Check Redis for this ID (48hr TTL)

const seen = await redis.get(`webhook:${webhookId}`);   if (seen) {     return res.status(200).json({ status: 'already_processed' });   }
Enter fullscreen mode Exit fullscreen mode

Mark as seen BEFORE processing

await redis.setex(`webhook:${webhookId}`, 172800, '1');    // Now safely process   await processOrderPaid(req.body);   res.status(200).json({ status: 'ok' }); });
Enter fullscreen mode Exit fullscreen mode

Why store BEFORE processing? If processing fails and you stored AFTER, the next retry will be treated as a duplicate and skipped. Store the key first, then process, and let your retry logic handle actual failures separately.

Strategy 3: Safe Retries with Exponential Backoff

async function retryWithBackoff(fn, key, maxRetries = 5) {   for (let attempt = 0; attempt < maxRetries; attempt++) {     try {       return await fn(key); // always pass same key     } catch (err) {       if (attempt === maxRetries - 1) throw err;       const baseDelay = Math.pow(2, attempt) * 1000;       const jitter = Math.random() * 400 - 200;       await sleep(baseDelay + jitter);     }   } }
Enter fullscreen mode Exit fullscreen mode

The jitter prevents thundering herd problems when many clients retry simultaneously after an outage.

Strategy 4: Database-Level Unique Constraints
App-level checks can fail under race conditions. Two requests can both pass your "does this exist?" check before either writes. Add a database constraint as the hard backstop.

PostgreSQL: prevent duplicate order processing

CREATE UNIQUE INDEX idx_idempotency   ON processed_events (idempotency_key);  -- Catch the constraint violation in your app try {   await db.query(     'INSERT INTO processed_events (idempotency_key, result) VALUES ($1, $2)',     [key, JSON.stringify(result)]   ); } catch (err) {   if (err.code === '23505') { // unique violation     const existing = await db.query(       'SELECT result FROM processed_events WHERE idempotency_key = $1',       [key]     );     return JSON.parse(existing.rows[0].result); // return cached result   }   throw err; }
Enter fullscreen mode Exit fullscreen mode

GraphQL: No Native Header, So Use Upserts
Shopify's GraphQL API does not support the Idempotency-Key header. Design mutations to be naturally idempotent instead.

Use productUpdate (upsert) instead of productCreate where possible

mutation UpdateProductInventory($id: ID!, $qty: Int!) {   inventoryAdjustQuantity(input: {     inventoryItemId: $id     availableDelta: $qty   }) {     inventoryLevel { available }     userErrors { field message }   } }
Enter fullscreen mode Exit fullscreen mode

For operations with no upsert, check for existing records first and return the existing result if found.

Quick Reference: What to Use Where

REST mutations: Idempotency-Key header + store key before request
GraphQL mutations: Upserts + client-side dedup table
Webhooks: X-Shopify-Webhook-Id + Redis with 48hr TTL
Background jobs: Job ID as idempotency key + status tracking
Database: Unique constraint on idempotency_key as final guard

Further Reading
Shopify Webhooks Deep Dive | Fault-Tolerant Shopify Integration | Queue-Based Webhook Processing

What idempotency patterns do you use in your Shopify stack?
Read more

Top comments (0)