Este artículo actualmente está disponible solo en inglés. La traducción estará disponible próximamente.
Shopify Webhooks 2026: HMAC, Retries, Idempotency in Production
Shopify webhooks deliver events for orders, products, fulfillments, customers, and 80+ other topics. They are at-least-once: Shopify will retry on any non-2xx response, on connection timeouts, and on certain 4xx codes. This means your receiver must verify HMAC signatures, return 200 fast, and process work idempotently. Get any of these wrong and you'll see duplicate orders, missed inventory updates, or — worse — a webhook subscription auto-deleted because Shopify decided your endpoint is unreliable. This guide is the production playbook we use across all our Shopify integrations.
Key Takeaways
- Shopify retries failed webhooks with exponential backoff for up to 48 hours
- After 19 consecutive failures, Shopify auto-deletes the subscription — your app must re-register on app load
- HMAC verification is mandatory; use timing-safe comparison or you create a side-channel attack vector
- Return 200 within 5 seconds — push slow work to a queue
- Use the
X-Shopify-Webhook-Idheader for idempotency, not order/product IDs (which can repeat across events)- The Webhook Subscription GraphQL mutation is the only supported registration path in 2026; REST registration is deprecated
- For mission-critical events, use Event Bridge or Pub/Sub destinations with managed retry semantics
Webhook delivery model
Shopify sends an HTTPS POST to your registered endpoint with a JSON body. Headers include:
| Header | Value |
|---|---|
X-Shopify-Topic | orders/create, products/update, etc. |
X-Shopify-Hmac-Sha256 | Base64 HMAC of the body |
X-Shopify-Shop-Domain | mystore.myshopify.com |
X-Shopify-Webhook-Id | Unique per delivery attempt; reused across retries |
X-Shopify-Triggered-At | ISO8601 timestamp of source event |
X-Shopify-Api-Version | 2026-01 |
Your endpoint must:
- Verify the HMAC against the raw body (not the parsed JSON).
- Respond 200 within ~5 seconds.
- Handle the same
X-Shopify-Webhook-Idarriving multiple times.
HMAC verification
import crypto from 'node:crypto';
export async function verifyShopifyHmac(req: Request): Promise<boolean> {
const signature = req.headers.get('x-shopify-hmac-sha256');
if (!signature) return false;
const rawBody = await req.text(); // critical: raw bytes, not JSON.stringify
const computed = crypto
.createHmac('sha256', process.env.SHOPIFY_API_SECRET!)
.update(rawBody, 'utf8')
.digest('base64');
// Timing-safe comparison
const a = Buffer.from(signature);
const b = Buffer.from(computed);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
Three ways this goes wrong in real apps:
- Re-serializing the JSON before HMAC — Express's
body-parsermutates whitespace, breaking the hash. Always read the raw body. - Using
===for comparison — leaks signature bytes through timing. Always usetimingSafeEqual. - Wrong secret — App Store apps use the API secret; webhook subscriptions can also be created with custom secrets via the GraphQL
webhookSubscriptionCreateformat: JSON, includeFields, callbackUrl. The webhook header is signed with the relevant secret per subscription.
Returning 200 fast
Shopify's retry rule: 5-second timeout per delivery attempt. That includes connect, TLS handshake, headers, body, and your response. If your DB write takes 200 ms and external API calls take 800 ms, you're already at 1+ seconds before any business logic — and during traffic spikes that can balloon.
The pattern:
export async function POST(req: Request) {
const rawBody = await req.text();
if (!await verifyHmac(req, rawBody)) {
return new Response('Unauthorized', { status: 401 });
}
const webhookId = req.headers.get('x-shopify-webhook-id')!;
const topic = req.headers.get('x-shopify-topic')!;
const shop = req.headers.get('x-shopify-shop-domain')!;
// Persist + enqueue, do NOT process inline
await db.insert(webhookEvents).values({
id: webhookId, // PRIMARY KEY — duplicate inserts no-op
topic, shop, payload: JSON.parse(rawBody),
receivedAt: new Date(),
status: 'pending',
}).onConflictDoNothing();
await jobQueue.enqueue('process-webhook', { webhookId });
return new Response(null, { status: 200 });
}
The handler does three things and returns. Your worker pulls from the queue, processes the event, and updates status to done or error. If the queue is unavailable, you can fall back to inline processing — but never let the receiver block on slow downstreams.
Idempotency: the right key
Shopify retries the same X-Shopify-Webhook-Id on failure. Use that as your dedup key, not the resource ID inside the payload. Reason: a single order can fire orders/create and orders/updated separately — they carry the same id but are different events.
CREATE TABLE webhook_events (
id text PRIMARY KEY, -- X-Shopify-Webhook-Id
topic text NOT NULL,
shop text NOT NULL,
payload jsonb NOT NULL,
received_at timestamptz NOT NULL,
processed_at timestamptz,
status text NOT NULL DEFAULT 'pending',
error text,
attempts int NOT NULL DEFAULT 0
);
CREATE INDEX ON webhook_events (status, received_at);
The PRIMARY KEY on id makes duplicate receives a no-op. Your worker reads pending rows, processes them, and updates status atomically.
Retries from Shopify's side
Shopify retries with backoff:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 15 minutes |
| ... | up to 48 hours total |
| 19 | Subscription deleted |
After 19 consecutive failures, the subscription is auto-deleted and Shopify emails the app's contact. You can re-register but you've lost all events delivered during the outage. Mitigation: monitor the webhookSubscriptions GraphQL query on app load and re-create any that are missing.
Re-registering subscriptions on app load
mutation {
webhookSubscriptionCreate(
topic: ORDERS_CREATE
webhookSubscription: {
callbackUrl: "https://yourapp.com/webhooks/orders-create"
format: JSON
}
) {
webhookSubscription { id topic }
userErrors { field message }
}
}
Run this on every app boot inside an idempotency check (webhookSubscriptions(topics: [ORDERS_CREATE]) first). If the topic isn't subscribed, create it. This recovers from auto-deletion automatically.
Higher reliability: EventBridge and Pub/Sub destinations
For mission-critical events (high-volume orders, inventory sync), Shopify supports two managed destinations beyond HTTP:
- Amazon EventBridge: Shopify writes to your EventBridge bus directly. Built-in retry (4 hours). You consume via Lambda or step functions.
- Google Cloud Pub/Sub: Shopify writes to a Pub/Sub topic. Pull subscribers process at their own pace. Retention up to 7 days.
These bypass HTTP entirely and shift retry semantics to AWS/GCP. Setup is more involved (IAM, Pub/Sub topic/IAM, etc.) but it's how Plus shops doing $50M+ should run.
mutation {
pubSubWebhookSubscriptionCreate(
topic: ORDERS_CREATE
pubSubWebhookSubscription: {
pubSubProject: "my-gcp-project"
pubSubTopic: "shopify-orders"
}
) { webhookSubscription { id } userErrors { field message } }
}
Webhook topic catalog (most-used)
| Topic | Use case |
|---|---|
orders/create | New order received |
orders/paid | Payment captured |
orders/fulfilled | Order shipped |
orders/cancelled | Order voided |
orders/updated | Any field change |
products/create / update / delete | Catalog sync |
inventory_levels/update | Stock changes per location |
customers/create / update | CRM sync |
app/uninstalled | Cleanup, mandatory |
customers/data_request | GDPR, mandatory |
customers/redact | GDPR, mandatory |
shop/redact | GDPR, mandatory |
The four mandatory ones must be subscribed for App Store approval.
Local development with ngrok or Cloudflare Tunnel
Shopify CLI handles tunneling:
shopify app dev
This exposes your local server via Cloudflare Tunnel and registers a temporary callback URL. For ad-hoc testing without the CLI:
ngrok http 3000
# then update webhook subscription callbackUrl to https://abc123.ngrok.io/webhooks
Common production bugs we've seen
- Returning 200 before HMAC verify — invites attackers to spoof events. Verify first, then 200 only after the queue accept.
- Storing payload as text instead of JSONB — kills downstream querying. Use
jsonb. - Letting workers retry forever — set max attempts (typically 5) and route to a dead-letter queue with PagerDuty.
- Missing the
attemptscolumn — when debugging, you need to know if a retry storm is from Shopify (X-Shopify-Webhook-Idrepeated) or your worker (same row processed many times).
Frequently Asked Questions
What's the difference between webhooks and Functions?
Webhooks deliver events to your server for async processing — order created, customer signed up, etc. Functions run synchronously inside Shopify's request path to customize discounts, delivery, payment. They don't replace each other; most apps use both.
Can I subscribe to a webhook from a custom app?
Yes. Custom apps register webhooks the same way as public apps, via the GraphQL webhookSubscriptionCreate mutation. The admin-issued access token must have the relevant scope (e.g., read_orders for orders/*).
How do I handle duplicate orders/create events?
Use X-Shopify-Webhook-Id as the idempotency key. Store with a unique constraint and ON CONFLICT DO NOTHING. If you see the same order ID arriving with different webhook IDs, that's expected — Shopify can re-send create events on order edits.
What's the right queue for webhook processing?
Anything durable: Redis-backed BullMQ, AWS SQS, GCP Pub/Sub, RabbitMQ. Avoid in-memory queues — a process restart loses pending work, and Shopify won't re-deliver if you returned 200. We use BullMQ on Redis for most ECOSIRE-built integrations.
How fast does Shopify expect a response?
5 seconds. We aim for under 500 ms total (HMAC verify + persist + enqueue + return). Anything slower, and during BFCM-level traffic you'll see retry storms.
ECOSIRE builds Shopify-to-Odoo, Shopify-to-NetSuite, and Shopify-to-custom-backend integrations on top of bulletproof webhook receivers. Our Shopify integration team ships at-least-once-with-idempotency receivers, Pub/Sub fan-out, and dead-letter monitoring as standard. See our Shopify-Odoo connector deep dive for a full integration architecture.
Escrito por
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
ECOSIRE
Escala tu tienda Shopify
Servicios personalizados de desarrollo, optimización y migración para comercio electrónico de alto crecimiento.
Artículos relacionados
Tutorial de Shopify App Bridge 4: cree aplicaciones integradas en 2026
Cree aplicaciones de administración integradas de Shopify con App Bridge 4: tokens de sesión, intercambio de tokens, navegación, modales, selectores de recursos y configuración de Polaris React 13.
Shopify Functions 2026: descuentos, entrega, personalización de pagos
Cree funciones de Shopify para descuentos, personalización de tasas de entrega, filtrado de métodos de pago y validación de carritos. Ejemplos de óxido + JavaScript.
Shopify GraphQL Admin API 2026: Guía completa para desarrolladores
Domina la API GraphQL de Shopify Admin: consultas, mutaciones, OAuth, costo de consulta calculado, límites de velocidad, operaciones masivas y ejemplos de código de producción.