Shopify webhooks: testing & verification guide

Shopify signs every webhook with X-Shopify-Hmac-Sha256: a base64 (not hex) HMAC-SHA256 of the raw body. Test by pointing a subscription at a free webhooks.sh bin (no signup); failed deliveries retry 8 times over 4 hours, and repeated failures can get the subscription deleted.

Shopify webhooks look like every other webhook until verification fails on every single request. The usual cause: Shopify signs with base64, not the hex digest that Stripe and GitHub trained you to expect. This guide covers setup, the headers that matter, verification that actually passes, and the retry behavior that deletes your subscription if you ignore it.

Two ways to create a webhook

  • In the admin. Settings → Notifications → scroll to Webhooks → Create webhook. Store-level subscriptions, created by hand, visible only in that UI — they do not show up in Admin API queries.
  • As an app. Declare subscriptions in your shopify.app.toml ([[webhooks.subscriptions]]) or create them with the GraphQL Admin API's webhookSubscriptionCreate mutation. These belong to the app and apply to every shop that installs it.

The distinction matters later, because the signing secret differs between the two paths.

Get a URL you can watch

Create a free bin at view.webhooks.page (no signup) and paste the ingest URL as the webhook address. Then trigger an event in the store — create a product, place a test order — and watch the delivery land with every header intact. For app webhooks, the Dev Dashboard's Monitoring page shows delivery counts and response times per topic; the bin still shows you the raw bytes.

Want to rehearse a payload before triggering the real thing? Our webhook payload generator builds realistic bodies you can replay against your handler.

What Shopify sends

Deliveries are POST with Content-Type: application/json and a stack of X-Shopify-* headers:

HeaderMeaning
X-Shopify-TopicThe event: orders/create, products/update, …
X-Shopify-Shop-DomainThe shop that triggered it: your-store.myshopify.com
X-Shopify-Hmac-Sha256Base64 HMAC-SHA256 of the raw body — the signature to verify.
X-Shopify-Webhook-IdUnique delivery ID — your dedupe key.
X-Shopify-API-VersionThe API version the payload conforms to.
X-Shopify-Triggered-AtWhen the event fired; retries carry the original timestamp.

Verify X-Shopify-Hmac-Sha256

The signature is base64(HMAC-SHA256(secret, rawBody)) — over the exact bytes Shopify sent. Which secret depends on how the subscription was created:

  • App webhooks (config file or API): your app's client secret — the API secret key from the app's credentials. One secret signs deliveries from every shop.
  • Admin-created webhooks (Settings → Notifications): Shopify shows a signing key directly below the webhooks list on that page. Use that, not an app secret.

Node/Express, done right:

import crypto from "node:crypto";
import express from "express";

const app = express();

// Raw body, not express.json() — parsing destroys the bytes you must verify.
app.post("/webhooks/shopify", express.raw({ type: "application/json" }), (req, res) => {
  const theirHmac = req.get("X-Shopify-Hmac-Sha256") ?? "";

  const digest = crypto
    .createHmac("sha256", process.env.SHOPIFY_WEBHOOK_SECRET!)
    .update(req.body)
    .digest();

  const theirs = Buffer.from(theirHmac, "base64");
  const valid =
    theirs.length === digest.length && crypto.timingSafeEqual(theirs, digest);

  if (!valid) return res.sendStatus(401);

  const topic = req.get("X-Shopify-Topic");
  res.sendStatus(200); // ack fast — slow work belongs after the response
  // …handle the topic asynchronously…
});

Check your mental model on a captured delivery with our HMAC signature verifier (browser-only, nothing leaves your machine) — paste the raw body, the header, and the secret, and see the base64 digest match.

Payloads are API-versioned

Every subscription is pinned to an api_version — in the TOML file, in the API call, or chosen in the admin UI. The payload shape matches that version, and X-Shopify-API-Version tells you which one a delivery used. When you bump the version on an existing subscription, the shape can change under you: field renames and deprecations land with new versions. Pin deliberately, test the bump, don't drift.

Mandatory compliance webhooks

If your app is distributed through the Shopify App Store, three topics are not optional: customers/data_request, customers/redact, and shop/redact. Subscribe via compliance_topics in your app config or in the Partner Dashboard. The rules:

  • Publicly reachable endpoints. These can't live on localhost or behind an auth wall — Shopify must be able to POST to them from the open internet, any time, for the app's entire installed life.
  • Verify and acknowledge. Check the HMAC like any other delivery and answer with a 2xx promptly; the payloads carry customer and shop data subject to GDPR-style requests, so your handler also has to actually honor the deletion/disclosure.
  • They're tested. Shopify checks these endpoints during app review — a dead or unreachable compliance webhook blocks listing.

Retries and the subscription-deletion cliff

Shopify counts a delivery as failed if you don't return a 2xx within roughly 5 seconds — redirects and error codes included. Failed deliveries are retried up to 8 times over 4 hours with exponential backoff, and retries resend the original payload (check X-Shopify-Triggered-At before acting on stale data; when in doubt, fetch current state from the API).

The part that bites: if failures persist, Shopify deletes the webhook subscription entirely. Your handler doesn't just miss events — it silently stops receiving them until you recreate the subscription. Watch the Dev Dashboard monitoring page, alert on failures, and keep the 200-fast-process-async pattern religious.

Common pitfalls

  • Hex vs base64. Copy-pasted Stripe/GitHub verification compares hex digests. Shopify's header is base64 — decode the header (or encode your digest as base64) before comparing.
  • Verifying the parsed body. Re-serialized JSON changes bytes; the HMAC never matches. Same trap as Stripe — raw bytes only.
  • Wrong secret for the path. An admin-created webhook signed with the Notifications-page key will never verify against an app's client secret, and vice versa.
  • Answering slowly. Five seconds is the whole budget. Queue the work, ack immediately.
  • Ignoring dedupe. Retries and back-to-back events arrive; key on X-Shopify-Webhook-Id.

The fast feedback loop

  1. Create a bin at view.webhooks.page and wire it into an admin webhook (fastest) or a dev-store app subscription.
  2. Trigger real events — products/create is one click in the admin.
  3. Verify a captured delivery offline in the verifier, then port the check into your handler with the raw-body route above.
  4. Point the subscription at your app, and keep monitoring on — the subscription-deletion cliff is the failure mode that never pages you until it's too late.

Last updated

Try it in 10 seconds.

Create a free bin at view.webhooks.page — no signup, no install. Point your sender at the URL and watch requests land live. Bins expire after 48 hours.