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.
SHOPIFY_CLIENT_SECRET=your-client-secretCreate 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.
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 devStart 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-hooksHorizon prints HORIZON: Tunnel connected. Your public URL is https://my-shop-hooks.hrzn.run.
Reserving a subdomain, so it stays yours across restarts, is a paid feature. See Pricing.
Subscribe to a topic
Declare the subscription in your app's shopify.app.toml. Point uri at your tunnel URL.
[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 deployShopify 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-secretUse 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/shopifyYour Next.js terminal prints Verified Shopify webhook: orders/create.
Troubleshooting
The signature doesn't match
Check these three causes, in order:
- 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, callrequest.text(), notrequest.json(). - Secret. Use your app's client secret, not an API key.
- 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
- Compare plans on Pricing.
Test GitHub webhooks locally
Receive GitHub webhook events on localhost with a Horizon tunnel, and verify the X-Hub-Signature-256 signature.
Test Slack events locally
Receive Slack Events API requests on localhost with a Horizon tunnel, answer the URL verification challenge, and verify request signatures.