Horizon
GuidesWebhooks

Test Shopify webhooks locally

Receive Shopify webhook events on localhost with a Horizon tunnel, and verify the X-Shopify-Hmac-Sha256 signature in Next.js.

Receive Shopify webhooks on your laptop while you build, with a stable HTTPS URL Shopify can reach.

Shopify sends each webhook as an HTTPS POST to a public URL. Your localhost isn't public. Horizon gives it a public URL.

Before you begin

  • Node.js 18 or later
  • A Horizon account and the CLI (see Getting started)
  • A Shopify app with the Shopify CLI installed
  • Your app's client secret, from the Shopify dev dashboard

Start your app

Shopify signs every webhook with your app's client secret. Store it in .env.local.

.env.local
SHOPIFY_CLIENT_SECRET=your-client-secret

Create the route handler. It reads the raw body, computes the base64 HMAC SHA-256 digest, and compares it to the X-Shopify-Hmac-Sha256 header in constant time.

app/api/webhooks/shopify/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export async function POST(request: Request) {
  const rawBody = await request.text();
  const received = request.headers.get("x-shopify-hmac-sha256") ?? "";

  const expected = createHmac("sha256", process.env.SHOPIFY_CLIENT_SECRET!)
    .update(rawBody, "utf8")
    .digest("base64");

  const receivedBuffer = Buffer.from(received);
  const expectedBuffer = Buffer.from(expected);
  const isValid =
    receivedBuffer.length === expectedBuffer.length &&
    timingSafeEqual(receivedBuffer, expectedBuffer);

  if (!isValid) {
    return new Response("Invalid signature", { status: 401 });
  }

  const topic = request.headers.get("x-shopify-topic");
  console.log("Verified Shopify webhook:", topic);

  return new Response("OK", { status: 200 });
}

Start Next.js on port 3000.

npm run dev

Start a tunnel

Run the tunnel with -s to pick a subdomain. Without -s the subdomain is random and changes every run, so a URL you saved in Shopify stops working after a restart.

hrzn tunnel http://localhost:3000 -s my-shop-hooks

Horizon prints HORIZON: Tunnel connected. Your public URL is https://my-shop-hooks.hrzn.run.

Subscribe to a topic

Declare the subscription in your app's shopify.app.toml. Point uri at your tunnel URL.

shopify.app.toml
[webhooks]
api_version = "2024-07"

  [[webhooks.subscriptions]]
  topics = ["orders/create"]
  uri = "https://my-shop-hooks.hrzn.run/api/webhooks/shopify"

Set api_version to the version your app uses. Then release the configuration.

shopify app deploy

Shopify documents this deploy step in Subscribe to webhooks.

Send a test event

Use the Shopify CLI to send a sample payload to your tunnel URL. Pass --client-secret so Shopify CLI signs the request and adds the X-Shopify-Hmac-Sha256 header.

shopify app webhook trigger \
  --topic orders/create \
  --api-version 2024-07 \
  --delivery-method http \
  --address https://my-shop-hooks.hrzn.run/api/webhooks/shopify \
  --client-secret your-client-secret

Use the same API version as your app. You can also create a real order in your dev store to fire the topic.

Check it works

The Horizon terminal prints one line per request:

HORIZON: Tunnel connected
  POST | [200] | /api/webhooks/shopify

Your Next.js terminal prints Verified Shopify webhook: orders/create.

Troubleshooting

The signature doesn't match

Check these three causes, in order:

  1. Raw body. Compute the HMAC over the raw request body. Shopify warns that a body parser such as express.json() parses the body before your verification code runs. In a Next.js route handler, call request.text(), not request.json().
  2. Secret. Use your app's client secret, not an API key.
  3. Header. Read X-Shopify-Hmac-Sha256. The value is base64-encoded.

If you test with shopify app webhook trigger, pass --client-secret. Without it, the request has no signature header to verify.

Shopify reports a failed delivery

Return a 200 response fast. Shopify treats any response outside the 200 range, including 3XX, as an error. It has a one-second connection timeout and a five-second timeout for the whole request. Do slow work after you respond.

If Shopify gets no response or an error, it retries 8 times over the next 4 hours. After 8 consecutive failures, it deletes a subscription configured through the Admin API.

Deliveries stopped after you restarted the tunnel

You started the tunnel without -s. The subdomain was random, so the URL in your shopify.app.toml points nowhere. Restart with the same -s value, or update uri and run shopify app deploy again.

Next steps

On this page