HookGet Start free

Shopify webhook verification, retries and deduplication

Shopify signs every webhook with HMAC-SHA256 over the raw request body, base64-encoded in the X-Shopify-Hmac-Sha256 header, keyed with your app's client secret. Your endpoint has five seconds to answer 200. A failed delivery is retried up to eight times over four hours, and a subscription that keeps failing inside a 24-hour period is removed. Deduplicate on X-Shopify-Webhook-Id and reconcile through the Admin API after any longer outage.

In short

  • HMAC-SHA256 over the raw body, base64, in X-Shopify-Hmac-Sha256; the key is the app client secret, or the admin page secret for merchant-created webhooks.
  • 1-second connect, 5-second total timeout, 200 required. Up to 8 retries over 4 hours; repeated failure inside 24 hours removes the subscription.
  • Deduplicate on X-Shopify-Webhook-Id; group on X-Shopify-Event-Id; order by X-Shopify-Triggered-At, never by arrival.
  • Verification: the provider’s own HMAC signature over the raw body.
  • Deduplication: X-Shopify-Webhook-Id.
  • Once verified, the event joins your catalogue with the same retries, log and replay as anything you publish yourself.

How do I verify a Shopify webhook HMAC in Node?

Shopify computes HMAC-SHA256 over the exact bytes of the request body and sends it base64-encoded in X-Shopify-Hmac-Sha256. Compute the same digest with the secret below, decode both to bytes, and compare in constant time. Verify before any JSON middleware runs: a body that has been parsed and re-serialised is a different byte sequence, and the check will fail on every request while looking correct in a unit test.

import { createHmac, timingSafeEqual } from 'node:crypto';

/** Shopify: HMAC-SHA256 over the raw body, base64 in X-Shopify-Hmac-Sha256. */
export function verifyShopify(secret, header, rawBody) {
  const expected = createHmac('sha256', secret).update(rawBody).digest('base64');
  const a = Buffer.from(expected, 'base64');
  const b = Buffer.from(String(header ?? ''), 'base64'); // compare the bytes, not the strings
  return a.length === b.length && timingSafeEqual(a, b);
}

Which secret signs a Shopify webhook?

It depends on who created the subscription, and this is the most common reason a correct verification function returns false.

Subscription created bySigning keyWhere to find it
Your app (app configuration, or the Admin API with the app's token)The app's client secret (also called the API secret key)Partner Dashboard → the app → Client credentials
A merchant, in the Shopify adminThe shared secret shown on that pageSettings → Notifications → Webhooks, at the bottom of the list

After rotating the client secret, Shopify says it can take up to an hour before deliveries are signed with the new value. Verify against both secrets for that hour, or expect a burst of mismatches.

How does Shopify retry a failed webhook?

Shopify waits one second for the connection and five seconds for the whole request, and it wants a 200. Anything else, including a slow 200, is a failed delivery. A failed delivery is retried up to eight times over the next four hours. A subscription that keeps failing inside a 24-hour period is removed, and a removed subscription receives nothing until it is created again. Shopify states plainly that delivery is not guaranteed and that an app should not rely on webhooks as its only source of truth.

BehaviourShopify's figure
Connection timeout1 second
Total request timeout5 seconds
Success200 OK; any other status or a timeout counts as an error
Retries after a failureUp to 8 attempts over 4 hours
Subscription removalAfter multiple failures within a 24-hour period; removed subscriptions receive nothing until recreated
OrderingNot guaranteed within a topic, nor across topics for the same resource
Delivery guaranteeNone stated; reconcile through the Admin API after an outage
Client secret rotationUp to 1 hour before deliveries use the new secret
What happens to one Shopify webhook delivery: 5 seconds to answer 200, up to 8 retries over 4 hours, subscription removed after failures across 24 hours, then reconciliation through the Admin API Flow diagram. Shopify sends a POST with a 1-second connection timeout and a 5-second total timeout. Your endpoint verifies the HMAC, inserts the delivery keyed on X-Shopify-Webhook-Id, and returns 200; that path ends in Delivered. An error or a slow answer goes to Retry, up to 8 attempts over 4 hours, which sends the same delivery back to the endpoint. If failures continue over 24 hours the subscription is removed, and the missed data has to be reconciled through the Admin API and replayed through the same handler. Shopify sends one delivery Your endpoint verify HMAC, insert on Webhook-Id, 200 Delivered Retry up to 8 attempts over 4 h Subscription removed nothing arrives until recreated Reconcile through the Admin API updated_at_min = outage start POST 1 s connect, 5 s total 200 within 5 s still failing across 24 h re-subscribe, then error, or slower than 5 s same Webhook-Id replay via the same handler
One Shopify delivery, start to finish: 5 seconds to answer 200, up to 8 retries over 4 hours on the same X-Shopify-Webhook-Id, removal of the subscription after failures across 24 hours, and the reconciliation path through the Admin API that replays missed data through the same handler.

Four hours is short. A deploy that breaks the route on a Friday evening, or a database that is down for a morning, is enough to exhaust the retries for every order in that window. The handler below answers in milliseconds because it does nothing but verify and insert; the work happens afterwards, from the stored row.

import express from 'express';
import { verifyShopify } from './verify-shopify.mjs';

const app = express();

// Five seconds for the whole request, one of them for the connection. Nothing
// slower than a database insert belongs inside this function.
app.post('/webhooks/shopify', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verifyShopify(process.env.SHOPIFY_CLIENT_SECRET, req.get('x-shopify-hmac-sha256'), req.body)) {
    return res.status(401).send('hmac mismatch');
  }

  // CREATE TABLE shopify_webhooks (
  //   webhook_id text PRIMARY KEY, event_id text, topic text NOT NULL, shop text NOT NULL,
  //   triggered_at timestamptz, payload jsonb NOT NULL, processed_at timestamptz);
  const { rowCount } = await db.query(
    `INSERT INTO shopify_webhooks (webhook_id, event_id, topic, shop, triggered_at, payload)
     VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (webhook_id) DO NOTHING`,
    [
      req.get('x-shopify-webhook-id'),
      req.get('x-shopify-event-id'),
      req.get('x-shopify-topic'),
      req.get('x-shopify-shop-domain'),
      req.get('x-shopify-triggered-at'),
      JSON.parse(req.body),
    ],
  );

  res.status(200).end(); // a retry of the same delivery lands on the same primary key: rowCount 0
});

Which header do I deduplicate on: X-Shopify-Webhook-Id or X-Shopify-Event-Id?

Deduplicate on X-Shopify-Webhook-Id. Shopify documents it as the unique key of an individual delivery, and a retry of that delivery repeats it. X-Shopify-Event-Id is shared by every delivery the same merchant action produced, across topics and across subscriptions, so it is the key for grouping, not for skipping: two deliveries with the same event id and different topics are two different facts about one action.

HeaderWhat Shopify puts in it
X-Shopify-TopicThe topic, for example orders/create
X-Shopify-Hmac-Sha256Base64 HMAC-SHA256 of the raw body
X-Shopify-Shop-DomainThe myshopify.com domain of the store
X-Shopify-API-VersionThe API version the payload was serialised with
X-Shopify-Webhook-IdUnique per delivery; the deduplication key
X-Shopify-Event-IdShared by all deliveries from the same merchant action
X-Shopify-Triggered-AtWhen Shopify triggered the delivery; use it, or the payload's updated_at, to order events
X-Shopify-NameOptional: the subscription name you set

What do I do about the deliveries Shopify gave up on?

Fetch them. Shopify's own advice for an outage is to pull the data from the affected period through the Admin API and feed it into the same code path the webhook would have taken. Keep the handler's business logic behind a function that takes a payload and a topic, not a request, and reconciliation is a loop over an API page rather than a second implementation.

# After an outage longer than Shopify's four-hour retry window, the missed
# changes are not coming back on their own. Ask the Admin API for everything
# updated since the outage began and feed it through the same handler.
curl -s "https://$SHOP.myshopify.com/admin/api/2025-07/orders.json?status=any&updated_at_min=2026-09-07T02:00:00Z&limit=250" \
  -H "X-Shopify-Access-Token: $SHOPIFY_ACCESS_TOKEN"

Receive Shopify webhooks through a verified HookGet source

Receiving Shopify webhooks through HookGet

Everything above is what the receiving end has to do by hand. A HookGet source does the verification and the deduplication before the event exists, and the event then gets the retry schedule, dead-letter queue and per-attempt log described on the rest of this site.

How an inbound webhook is verified before it enters A provider sends a signed request. HookGet verifies the signature or token and deduplicates on the provider's delivery id before the event exists. A request that fails verification is refused with a 401 and never enters the pipeline. A verified event flows into the same pipeline as any other: retries, timeline, replay. The provider GitHub, Stripe, 13 more Verified at the door signature or token · dedupe The same pipeline retries · timeline · replay Refused bad signature → 401, nothing enters
Verification happens before the event exists, so a forged request is refused at the door — it is never stored, never retried, never seen again. Redeliveries are deduplicated on the provider’s own delivery id.

The contract

FieldValue
Provider keyshopify
Header checkedX-Shopify-Hmac-Sha256: <base64>
SchemeHMAC-SHA256 over the raw request body, base64-encoded, compared in constant time.
Deduplicated onX-Shopify-Webhook-Id
Event typeX-Shopify-Topic becomes shopify.orders.create and similar.

Setting it up

# 1. create a verified source; the secret is shown once
curl -X POST https://api.hookget.com/v1/sources \
  -H "authorization: Bearer $HOOKGET_KEY" \
  -H "content-type: application/json" \
  -d '{"provider":"shopify","name":"Production"}'

# {"id":"src_…","secret":"whsec_…","ingest_path":"/ingest/src_…"}
  1. In the Shopify admin, open Settings → Notifications → Webhooks, or create the webhook through the Admin API.
  2. Paste the ingest URL as the destination and the source secret as the shared secret.
  3. Pick the topics you need; the topic header names the event on our side.

Worth knowing. Shopify computes its HMAC over the exact bytes it sent. Any middleware that reformats JSON before verification breaks the check — verify first, parse second.

What you get after verification

  • The event appears in your log with a namespaced type, so Shopify traffic never collides with your own.
  • It fans out to your destinations with the same retry schedule and dead-letter behaviour as any other event.
  • The source secret rotates with an overlap window, so you can update Shopify at your own pace.
  • Each inbound source is rate limited on its own, so a busy provider cannot exhaust your publish budget.

Questions

How many times does Shopify retry a failed webhook?

Up to eight times over four hours. Your endpoint must return 200 within five seconds, one of which is allowed for the connection. After multiple failures inside a 24-hour period the subscription is removed and nothing arrives until it is created again.

Why is my Shopify HMAC verification failing?

Most often the wrong key: app-created subscriptions are signed with the app's client secret, merchant-created ones with the secret on the admin's Notifications page, and a rotated secret takes up to an hour to apply. Next most often, the body was parsed before verification, or the base64 strings were compared instead of the decoded bytes.

Should I deduplicate on X-Shopify-Webhook-Id or X-Shopify-Event-Id?

On X-Shopify-Webhook-Id, which is unique per delivery and repeated by a retry of that delivery. X-Shopify-Event-Id is shared by every delivery the same merchant action produced across topics and subscriptions, so it groups related deliveries rather than identifying duplicates.

Does Shopify deliver webhooks in order?

No. Shopify does not guarantee ordering within a topic or across topics for the same resource. Order by X-Shopify-Triggered-At or the payload's updated_at, and when a decision depends on current state, fetch the object from the Admin API instead of trusting the last delivery to be the latest.

What happens to Shopify webhooks during an outage longer than four hours?

They are not redelivered. Shopify says delivery is not guaranteed and recommends fetching the data for the outage period from the Admin API and feeding it through your webhook processing code, which is why the business logic should take a payload rather than a request.

How do I verify a Shopify webhook?

HMAC-SHA256 over the raw request body, base64-encoded, compared in constant time. With HookGet you do not implement it: the source is created with a secret, and every request is checked against X-Shopify-Hmac-Sha256: <base64> before the event exists.

What happens if Shopify sends the same event twice?

Redeliveries are deduplicated on X-Shopify-Webhook-Id, namespaced per source, so a repeat becomes the same event rather than a second one.

What if verification fails?

The request is refused with 401 and nothing enters the pipeline. The rejection is logged on our side as a signal worth watching; the caller learns nothing beyond the status.

Start delivering webhooks today

Point your webhooks at HookGet and watch the first delivery arrive, signed, in under a minute.

Create a free account Try the free webhook tester

10,000 deliveries a month free, no credit card. The free tier blocks rather than bills, so trying it cannot produce an invoice.