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) });
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'];
Check Redis for this ID (48hr TTL)
const seen = await redis.get(`webhook:${webhookId}`); if (seen) { return res.status(200).json({ status: 'already_processed' }); }
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' }); });
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); } } }
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; }
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 } } }
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)