Shopify orders/paid Webhook Not Working: What to Check

SendPromptly Team
4 min read

A Shopify order can be paid while your app still does nothing useful with it. The customer completed checkout, Shopify recorded the order, and your webhook endpoint may even have returned 2xx. But your app never unlocked access, granted credits, triggered fulfillment, or updated the internal order state.

This is a post-payment fulfillment failure. Shopify can tell you whether the webhook was delivered. It cannot tell you whether your app applied the business effect after delivery.

Confirm the order and topic

Start with the source record in Shopify. Confirm the order is actually paid, then confirm which webhook topic your app expects.

For most paid-order workflows, the topic is orders/paid. Some apps also react to orders/fulfilled, but those are different lifecycle events. If your app waits for orders/fulfilled, a paid but unfulfilled order will not trigger the same path.

Check:

  • The order payment status in Shopify
  • The webhook topic your app registered
  • The endpoint URL Shopify is sending to
  • Whether that endpoint belongs to production, not a staging app

If the wrong environment receives the webhook and returns 2xx, Shopify delivery can look healthy while your production database never changes.

Verify the HMAC using the raw body

Shopify webhooks include an HMAC header. Your app should verify that signature before trusting the payload. The important detail is that verification must use the raw request body, before JSON parsing or request mutation.

Here is a runnable Node.js check for a captured raw body:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import crypto from "crypto";

const secret = process.env.SHOPIFY_WEBHOOK_SECRET;
const rawBody = process.env.RAW_BODY || "";
const received = process.env.SHOPIFY_HMAC || "";

const digest = crypto
  .createHmac("sha256", secret)
  .update(rawBody, "utf8")
  .digest("base64");

const expected = Buffer.from(digest, "utf8");
const actual = Buffer.from(received, "utf8");
const valid = expected.length === actual.length && crypto.timingSafeEqual(expected, actual);

console.log(valid ? "valid" : "invalid");

Run it with:

1
2
3
4
SHOPIFY_WEBHOOK_SECRET=shpss_xxx \
SHOPIFY_HMAC="header_value" \
RAW_BODY='{"id":123}' \
node verify-shopify-hmac.mjs

If this check fails in production but passes in local tests, inspect body parsing middleware, proxy layers, and any code that reformats JSON before verification.

Check whether your handler accepted work or applied work

A webhook handler often does two different things:

  1. Accepts the request and returns 2xx
  2. Enqueues or runs the business effect

Those are not the same outcome. A 2xx response means Shopify should stop retrying delivery. It does not prove your app fulfilled anything.

Look for logs that show:

  • Webhook received, with topic and Shopify order ID
  • HMAC verified
  • Job enqueued, with job ID and order ID
  • Job started
  • Business effect applied

If the first three logs exist but the last two do not, your queue or worker path is the likely failure. If the job ran but the app state is unchanged, inspect the fulfillment logic.

Watch for dedupe problems

Shopify may deliver webhooks more than once. Your handler needs dedupe, but dedupe can also block recovery if implemented in the wrong order.

Bad pattern:

  1. Write “processed order webhook” record
  2. Run fulfillment logic
  3. Fulfillment fails
  4. Retry sees the processed record and exits

Safer pattern:

  1. Start a transaction
  2. Claim the webhook or order effect with a unique key
  3. Apply the business effect
  4. Commit both records together

For access or credit effects, the safest key is usually the business action, such as shopify_order_id + effect_type, not only a delivery attempt identifier.

Recover the affected order

After you find the root cause, repair the order through the safest available path.

  • If the handler bug is fixed and idempotent, resend or reprocess through the normal handler path.
  • If the queue job exists but failed, retry the failed job after confirming it cannot double-apply the effect.
  • If only one order is affected, use an admin action that records who applied the repair and why.
  • Avoid direct database edits unless there is no application-level repair path.

After repair, verify app state directly: access is unlocked, credits exist, fulfillment status is correct, and the internal order record references the Shopify order.

SendPromptly monitors Shopify orders/paid flows the same way it monitors Stripe payment events: it waits for your app to report the business outcome, then opens an incident when that outcome is missing or failed.