
- Authors

- Name
- Nadim Tuhin
- @nadimtuhin
Most custom Shopify integrations work fine in development and immediately fail during a flash sale.
The breakdown almost always follows the same pattern: an unthrottled GraphQL query consumes the entire leaky-bucket credit allowance, or a burst of webhooks arrives out of order, overwriting current order state with stale data.
Having built production backend integrations for over a decade, here is the architecture I use to keep Shopify apps resilient under heavy load.
The Shopify Admin GraphQL throttling model
Unlike REST APIs that rate-limit by the number of requests per second, Shopify's Admin GraphQL API operates on a calculated query cost model using a leaky bucket algorithm.
Every standard store has a bucket capacity of 1,000 cost points with a restore rate of 50 points per second (Shopify Plus stores have higher limits).
When you make a request, Shopify calculates the maximum possible complexity of your query before execution. If your query costs 300 points, 300 points are drained from your bucket. If you send four such queries in parallel, the fourth call returns an HTTP 429 error.
Parsing throttle status on every response
Never guess how much budget remains. Every GraphQL response includes an extensions.cost payload:
{
"extensions": {
"cost": {
"requestedQueryCost": 252,
"actualQueryCost": 124,
"throttleStatus": {
"maximumAvailable": 1000.0,
"currentlyAvailable": 748.0,
"restoreRate": 50.0
}
}
}
}
A production GraphQL client must parse these numbers and back off before hitting a 429:
import { setTimeout } from 'timers/promises'
interface ThrottleStatus {
maximumAvailable: number
currentlyAvailable: number
restoreRate: number
}
export async function executeShopifyGraphQL<T>(
query: string,
variables: Record<string, unknown> = {}
): Promise<T> {
const response = await fetch('https://shop.myshopify.com/admin/api/2026-07/graphql.json', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Access-Token': process.env.SHOPIFY_ADMIN_ACCESS_TOKEN!,
},
body: JSON.stringify({ query, variables }),
})
const json = await response.json()
const throttle = json.extensions?.cost?.throttleStatus as ThrottleStatus | undefined
if (throttle) {
const safetyThreshold = 150
if (throttle.currentlyAvailable < safetyThreshold) {
const deficit = safetyThreshold - throttle.currentlyAvailable
const waitMs = Math.ceil((deficit / throttle.restoreRate) * 1000)
// Pause worker before dispatching next mutation
await setTimeout(waitMs)
}
}
if (json.errors) {
throw new Error(`Shopify GraphQL Error: ${JSON.stringify(json.errors)}`)
}
return json.data as T
}
When to use the Bulk Operations API
If your workload synchronizes more than 250 products or orders at a time, paginating GraphQL connections is an anti-pattern. You will spend all your available cost points navigating cursors.
Instead, dispatch a asynchronous bulk query via bulkOperationRunQuery:
mutation {
bulkOperationRunQuery(
query: """
{
products {
edges {
node {
id
title
variants {
edges {
node {
id
price
inventoryQuantity
}
}
}
}
}
}
}
"""
) {
bulkOperation {
id
status
}
userErrors {
field
message
}
}
}
Shopify compiles the dataset on their infrastructure and fires a bulk_operations/finish webhook containing a download URL for a JSONL file. Your application downloads the file in a single stream with zero GraphQL credit drain.
Webhook ingestion: solving the 3 production traps
Shopify webhook deliveries guarantee at-least-once delivery, not exactly-once or strictly in-order delivery.
| Failure Mode | Production Impact | Architectural Fix |
|---|---|---|
| Duplicate Delivery | Duplicate inventory deduction or double email dispatches | Redis distributed lock (SETNX) on X-Shopify-Webhook-Id |
| Out-of-Order Execution | Older webhook overwrites newer order updates | Monotonic timestamp verification against DB |
| Worker Timeout Cascades | Slow DB queries cause Shopify to drop the webhook endpoint | Decoupled ingest: return 200 in <50ms, process in BullMQ |
1. Raw buffer HMAC verification
Shopify signs payloads with SHA256 using your app's secret. A common bug is verifying the hash after body-parser has parsed JSON, which alters whitespace and breaks the digest.
import crypto from 'crypto'
export function verifyShopifyWebhook(
rawBodyBuffer: Buffer,
hmacHeader: string,
secret: string
): boolean {
const generatedHmac = crypto.createHmac('sha256', secret).update(rawBodyBuffer).digest('base64')
return crypto.timingSafeEqual(Buffer.from(generatedHmac, 'utf8'), Buffer.from(hmacHeader, 'utf8'))
}
2. Idempotency and monotonic ordering
To prevent duplicate runs and handle delayed packets that arrive after newer ones:
import { Redis } from 'ioredis'
import { db } from './db'
export async function processOrderWebhook(
redis: Redis,
webhookId: string,
payload: { id: string; updated_at: string; [key: string]: unknown }
) {
// 1. Deduplication lock (24h TTL)
const lockKey = `webhook:lock:${webhookId}`
const acquired = await redis.set(lockKey, '1', 'EX', 86400, 'NX')
if (!acquired) {
// Already ingested; ignore duplicate delivery
return
}
// 2. Monotonic order timestamp check
const incomingTime = new Date(payload.updated_at).getTime()
const existing = await db.orders.findUnique({
where: { shopifyOrderId: payload.id },
select: { lastShopifyUpdatedAt: true },
})
if (existing && existing.lastShopifyUpdatedAt) {
const existingTime = existing.lastShopifyUpdatedAt.getTime()
if (incomingTime <= existingTime) {
// Out-of-order delivery: a newer state is already stored
return
}
}
// 3. Apply state mutation inside transaction
await db.$transaction([
db.orders.upsert({
where: { shopifyOrderId: payload.id },
update: {
lastShopifyUpdatedAt: new Date(payload.updated_at),
data: payload,
},
create: {
shopifyOrderId: payload.id,
lastShopifyUpdatedAt: new Date(payload.updated_at),
data: payload,
},
}),
])
}
Embedded apps and iframe authentication
Modern browsers (Safari ITP, Chrome Privacy Sandbox) block third-party cookies by default. If your embedded app relies on standard Express session cookies inside the Shopify Admin iframe, your users will experience broken logins and redirect loops.
The only reliable approach is Shopify App Bridge Session Tokens:
- The frontend React component requests a short-lived JWT from App Bridge via
getSessionToken(app). - The JWT is attached to every API request in the
Authorization: Bearer <token>header. - The Node.js backend verifies the JWT using the app API secret:
import jwt from 'jsonwebtoken'
export function verifySessionToken(token: string, apiSecret: string) {
// Verifies issuer, signature, and expiration without relying on cookies
return jwt.verify(token, apiSecret, {
algorithms: ['HS256'],
}) as { dest: string; sub: string }
}
Summary architecture
- Frontend: React + Shopify Polaris embedded via App Bridge with Session Token auth.
- API Layer: Node.js (Fastify) with HMAC validation and fast 200 HTTP acknowledgement.
- Queue Layer: BullMQ + Redis for asynchronous processing, rate limiting, and webhook deduplication.
- Data Layer: PostgreSQL with monotonic timestamp guards and connection pooling via PgBouncer.
- Bulk Engine: GraphQL Bulk Operations API for heavy product and inventory catalog updates.
Building with these guardrails from day one prevents the silent data corruption and cascading rate limits that plague naive integrations.