Back to blog

Stop Losing Shopify Order Webhooks

webhookstutorialsshopifyreliability

It is a quiet Tuesday. You push a deploy, your app restarts, and for ninety seconds the orders/create endpoint returns errors. Nobody notices. A week later a merchant asks why three orders never showed up in your fulfillment system.

Nothing was “broken” on Shopify’s side. Your endpoint was down, Shopify tried, and then Shopify stopped trying. This post covers exactly what Shopify does when your endpoint fails, and how to put Hookbridge in front of your server so a bad deploy turns into a delay instead of a missing order.

What Shopify does when your endpoint fails

Shopify’s delivery rules are reasonable, but they are strict:

  • Tight timeouts. Shopify uses a one-second connection timeout and a five-second timeout for the whole request. Anything outside the 200 range, including a 3XX redirect, counts as a failure.
  • A bounded retry window. If there is no response or an error, Shopify retries 8 times over the next 4 hours.
  • Then it stops. After 8 consecutive failures, a subscription created through the Admin API is deleted automatically. Shopify sends warning emails to the app’s emergency developer email address, which is easy to miss if nobody reads that inbox.
  • Duplicates happen. Shopify works to minimize them, but the same webhook can arrive more than once, for example after a network timeout or a retry.
  • Delivery and order are not guaranteed. Webhooks can arrive out of order, and Shopify’s own best-practices page says delivery “isn’t always guaranteed.” It recommends a periodic reconciliation job against the Shopify API.

None of this is unfair. It is what any sender has to do. But it means your receiver needs to be up, fast, and idempotent, all the time. A five-second budget is tight if your handler writes to a database and calls three other services before it answers.

The idea: let Hookbridge catch the webhook first

Instead of pointing Shopify at your app, you point it at a Hookbridge inbound endpoint. The flow looks like this:

Shopify
   ↓  POST
Hookbridge ingest_url
   ↓  accepted and stored
Hookbridge forwards to your server
   ↓  if it fails, Hookbridge retries
Your order handler

Per the Hookbridge docs, when a provider POSTs to your ingest_url, Hookbridge validates the request, creates an inbound message, and forwards it to your destination URL. If forwarding fails, Hookbridge retries with exponential backoff and tracks the message status. Hookbridge also honors a Retry-After header from your server as a minimum delay.

The important part is that the two jobs are separated. Shopify only needs Hookbridge to accept the request. Whether your server is healthy at that moment is a separate problem, and Hookbridge handles it on its own schedule.

Step 1: Create an inbound endpoint

Create the endpoint in the console (Endpoints, then the Inbound tab, then Create Inbound Endpoint) or with the API. The forwarding URL must be https://.

curl -X POST https://api.hookbridge.io/v1/inbound-endpoints \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Shopify orders",
    "url": "https://your-app.example.com/webhooks/shopify",
    "verify_hmac": true,
    "hmac_header_name": "X-Shopify-Hmac-Sha256",
    "hmac_signature_encoding": "base64",
    "hmac_secret": "YOUR_SHOPIFY_CLIENT_SECRET"
  }'

The last four fields turn on signature checks at the edge. Every Shopify delivery carries an X-Shopify-Hmac-Sha256 header: an HMAC-SHA256 of the raw request body, signed with your app’s client secret, and base64-encoded. Hookbridge computes the same HMAC over the raw body and compares the two before it stores anything.

Do not skip hmac_signature_encoding. It defaults to hex, and Hookbridge does not guess, so a base64 signature read as hex fails every time. In the console, the same setting is the Signature encoding dropdown. Pick Base64.

The response includes an ingest_url and a secret_token. The docs note they are only returned once, so store them somewhere safe.

Step 2: Point your Shopify subscription at the ingest URL

Set the Shopify webhook subscription’s delivery address to your ingest_url. You can do this in your Shopify app configuration or through the Admin API, whichever you already use for the orders/create topic.

Then place a test order, or trigger a test notification, and check Messages in the Hookbridge console to see the inbound message and its forwarding status.

If the test shows up under Rejections instead, with the reason hmac_failed, the signature check did not pass. Fix it now, not later. Hookbridge answers a failed check with a 401, and Shopify counts that as a failed delivery. Eight in a row and Shopify deletes the subscription.

Step 3: Deduplicate with the Shopify webhook ID

Because both Shopify and Hookbridge retry, your handler should expect the same event twice. Shopify’s docs say to use X-Shopify-Webhook-Id to deduplicate individual deliveries.

Hookbridge can do part of this for you at the edge. Inbound endpoints take an ordered list of idempotency_header_names. When the same key shows up again on the same endpoint, Hookbridge does not enqueue or forward a second delivery, and it returns a success response to the sender.

curl -X PATCH https://api.hookbridge.io/v1/inbound-endpoints/YOUR_INBOUND_ENDPOINT_ID \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "idempotency_header_names": ["X-Shopify-Webhook-Id"]
  }'

Keep your own dedupe check anyway. It is cheap, and it protects you from your own retries and replays. A unique constraint on the webhook ID in your orders table is enough.

Step 4: Check the signature again in your handler

Hookbridge now drops forged requests before they reach you. So why check again?

Because your server’s URL is still public. Anyone who finds https://your-app.example.com/webhooks/shopify can POST to it directly and skip Hookbridge entirely. A second check in your handler closes that gap.

It is easy, because Hookbridge forwards the original body byte for byte and passes the Shopify headers through, so the signature arrives at your server untouched. (Hookbridge adds its own X-Hookbridge-* headers on top.) Here is plain Node with no extra library:

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

const app = express();
const SHOPIFY_SECRET = process.env.SHOPIFY_CLIENT_SECRET; // never hard-code this

app.post(
  "/webhooks/shopify",
  express.raw({ type: "application/json" }), // keep the raw bytes
  (req, res) => {
    const received = req.get("X-Shopify-Hmac-Sha256") || "";
    const expected = crypto
      .createHmac("sha256", SHOPIFY_SECRET)
      .update(req.body) // Buffer, exactly as sent
      .digest("base64");

    const a = Buffer.from(received);
    const b = Buffer.from(expected);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).send("bad signature");
    }

    const webhookId = req.get("X-Shopify-Webhook-Id");
    const order = JSON.parse(req.body.toString("utf8"));

    // 1. Insert (webhookId, order) with a unique constraint on webhookId.
    // 2. If it already exists, skip the work.
    // 3. Keep the handler fast, and do slow work in a background job.

    res.status(200).send("OK");
  }
);

app.listen(3000);

Two details matter here. Verify against the raw body, not re-serialized JSON, and compare with timingSafeEqual.

Your handler can also return a non-2xx status for a real failure, such as a database outage. Hookbridge will keep the message and retry it, which is the whole point.

Step 5: Know what to do when things go wrong

This is where Hookbridge changes the story.

Your server is down during a deploy. Shopify’s request is accepted by Hookbridge, so Shopify sees a success and your subscription stays healthy. Hookbridge’s forward to your server fails, the message goes to pending_retry, and it is retried with exponential backoff.

Retries ran out. A message that exhausts its retries becomes failed_permanent. From Messages, filter by that status, fix the underlying problem, and replay. You can replay one message or use Replay All by status:

curl -X POST "https://api.hookbridge.io/v1/inbound-messages/replay-all?status=failed_permanent&limit=100" \
  -H "Authorization: Bearer YOUR_API_KEY"

Replay needs the stored payload, so do it within your plan’s retention window.

You need a planned maintenance window. Pause the inbound endpoint before maintenance and resume it afterward (/pause and /resume on the inbound endpoint).

Something is being rejected. If a request fails the checks you configured, Hookbridge returns an error to the sender, records a rejection, and does not store or forward the request. Check Rejections in the console, or GET /v1/inbound-rejections, to see the reason. For signature problems the reason is hmac_failed, with a detail such as “HMAC signature mismatch” or “HMAC signature is not valid base64”.

Option: skip the public endpoint with a pull endpoint

If you would rather not expose a server to receive orders at all, a pull endpoint gives you the same ingest_url for Shopify, but stores the events instead of forwarding them. Your worker lists events, fetches the payload, processes it, and then acknowledges it.

# List events that have not been fetched yet
curl "https://api.hookbridge.io/v1/pull-endpoints/YOUR_PULL_ENDPOINT_ID/events" \
  -H "Authorization: Bearer YOUR_API_KEY"

# Fetch one event with its payload and original headers
curl "https://api.hookbridge.io/v1/pull-endpoints/YOUR_PULL_ENDPOINT_ID/events/EVENT_ID" \
  -H "Authorization: Bearer YOUR_API_KEY"

# Acknowledge it once it is safely processed
curl -X POST "https://api.hookbridge.io/v1/pull-endpoints/YOUR_PULL_ENDPOINT_ID/events/ack" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_ids": ["EVENT_ID"]}'

Events move from stored to fetched to delivered. If your worker crashes after fetching, the event is still there to retrieve again. Pull endpoints have the same signature options as inbound endpoints, so set hmac_signature_encoding to base64 there too. Because the original headers come back with the event, you can run the same signature check from Step 4 in the worker. This also means a deploy cannot drop an order, since there is nothing to be “down” when events are waiting for you to collect them.

Keep a reconciliation job anyway

Even with a safety net, Shopify recommends a periodic job that asks the Shopify API for orders updated since the last run. Keep one. Think of it as a backstop for the rare case that has nothing to do with your endpoint. Hookbridge makes the common case, a short outage or a bad deploy, a non-event.

Troubleshooting checklist

  • Orders missing in your app but present in Shopify. Check Messages in Hookbridge for pending_retry or failed_permanent, then fix and replay.
  • Nothing arrives in Hookbridge. Confirm the Shopify subscription address is the exact ingest_url, and check the Rejections list.
  • Rejections show hmac_failed. Check three things: hmac_signature_encoding is base64, hmac_secret is your app’s client secret, and hmac_header_name is X-Shopify-Hmac-Sha256.
  • Signature check fails in your handler. Make sure you hash the raw body, use your app’s client secret, and compare base64 to base64.
  • Duplicates in your database. Add a unique constraint on X-Shopify-Webhook-Id.

Try it

Put Hookbridge between Shopify and your app, and a restart, a bad deploy, or a slow database stops costing you orders. Create a free Hookbridge account, set up an inbound endpoint, and read the inbound verification docs for the full list of options.