Catching Shopify orders your webhook workflow never received

If your Shopify order workflow starts with a Shopify Trigger or webhook, it only runs when the webhook actually arrives. I’ve seen orders silently skipped in three ways:

  1. n8n was restarting or updating when Shopify sent the webhook. Shopify retries, but only for a limited time.
  2. The execution timed out or errored before the order was logged, and nobody re-ran it.
  3. After repeated failed deliveries Shopify removes the webhook subscription completely. From then on the workflow just never runs, with no error anywhere.

None of these show up as a failed execution, so you only notice when a customer asks about their order.

What I ended up building is a daily reconciliation instead of trusting the webhook:

  • Schedule Trigger at 8:00
  • HTTP Request to the Shopify Admin GraphQL API for yesterday’s orders (created_at:>=… created_at:<…), in a loop on pageInfo.hasNextPage / endCursor. Note: the HTTP node’s built-in pagination with the Shopify OAuth2 credential fails with “refreshToken is required”, so I loop manually with an IF node back to the request.
  • Google Sheets read of the order log the webhook workflow writes to
  • Code node that normalizes order names (#1001, 1001, or the numeric ID all match) and returns only the orders missing from the sheet
  • Send Email with the missed orders (admin link, payment and fulfillment status), then append them to the sheet so each one is reported only once
  • Error outputs on the Shopify and Sheets nodes go to an alert email, so a failing check doesn’t fail silently too

It’s one execution a day and only needs the read_orders scope. Optional filter by order tag if you only care about e.g. wholesale orders.

I made a short walkthrough of the build here: https://youtu.be/9G5w1tWDmUk

Curious how others handle this: do you reconcile against Shopify, or rely on webhook retries plus checking that the subscription still exists (webhookSubscriptions query)?

2 Likes

Good point about webhooks failing silently!

I’ve worked with Shopify order automations in Make.com, and this is an important issue. If a webhook never reaches the workflow, there’s no failed execution for the error handler to catch.

I like your daily reconciliation approach as an additional safety check, especially since you’ve also included error alerts for the reconciliation itself.

Thanks for sharing!

Great approach. Webhook retries alone can’t guarantee that every business event was processed successfully.

I’ve been testing a similar reliability principle in my n8n + FastAPI + PostgreSQL demo: idempotency, duplicate prevention, and database-failure recovery.

Daily reconciliation adds another important layer by detecting events that never reached the workflow in the first place. I’d also consider comparing stable Shopify order IDs and tracking reconciliation status so that recovery doesn’t create duplicate business actions.

Thanks for sharing this practical solution!

Thanks @Yana1, exactly: no execution means nothing for the error handler to catch, so the check has to come from outside the webhook path.

@Shital_Katre good point on duplicates. Two things keep it safe in this setup:

  • The matching uses the stable order ID as well as the order name (#1001, 1001 or the numeric ID all match the same row), so a renamed or re-formatted name doesn’t create a false “missed” order.
  • The check doesn’t replay the original workflow. It alerts and appends the missed orders to the log, and that row is the reconciliation status: the next run sees it and won’t report the order again. Re-processing stays a human decision, so a late webhook plus a reconciliation can’t trigger the same business action twice.

If you did want automatic recovery, I’d add an idempotency key (the Shopify order ID) checked in the downstream workflow before acting, the same as your PostgreSQL approach.

1 Like

Thanks for the clarification! I like the decision to separate reconciliation from re-processing and keep recovery under human control.

Using the stable Shopify order ID as an idempotency key in the downstream workflow is a sensible next step if automatic recovery is introduced. It helps ensure that a late webhook or reconciliation process doesn’t trigger the same business action twice.

Appreciate the detailed explanation!

The subscription quietly disappearing is nasty. Does your reconciliation also check that the webhook still exists, or does it only catch order-count mismatches?

Great question — and this is exactly the gap to watch for. Order reconciliation only catches what already slipped through. It can’t detect a dead subscription, because a deleted subscription produces nothing at all: no orders, no mismatch, just silence. The reconciliation would look perfectly healthy while new orders stop coming in.

So I’d run it as a separate, dedicated health check. Once a day (or right before the reconciliation run): a Schedule Trigger → an HTTP Request node hitting the Admin GraphQL API with a webhookSubscriptions query → an IF node checking that the orders/create topic with your callback URL is still there → an alert (email or Slack) when it isn’t. If you want it to self-heal, the same workflow can recreate the subscription via the webhookSubscriptionCreate mutation.

In short, the full pattern is two layers doing different jobs: the subscription health check prevents future silence, and the order reconciliation catches historical misses. One without the other leaves a hole.

@nachiketmistry it doesn’t query the subscription itself, but it still catches a dead one. The check doesn’t compare counts: it asks Shopify for every order created yesterday and looks each one up in the log. If the subscription is gone, orders keep coming in on Shopify’s side and nothing reaches the log, so the next morning every order from yesterday shows up as missed. One alert like that tells you the webhook is dead, not that one order slipped.

So I’d put it slightly differently from @Shafique_Mumtaz: the reconciliation isn’t blind to a dead subscription, it’s just up to a day late, and it can’t tell “no webhook” from “no orders” on a day with zero sales. A webhookSubscriptions check is the faster layer and worth adding if a day’s delay matters. Two things to know if you build it:

  • The query only returns subscriptions owned by the app behind your token, so use the same Shopify app/credential the trigger uses, or the list comes back empty and looks like a failure.
  • With the n8n Shopify Trigger, deactivating and re-activating the workflow re-registers the webhook, which is often simpler than recreating it with webhookSubscriptionCreate.

@Shital_Katre thanks, agreed.

1 Like

Agreed — the two-layer approach makes sense. A subscription health check detects configuration problems earlier, while reconciliation helps recover missed orders. I’d also log the last successful subscription check and reconciliation timestamp so that a failed monitoring run doesn’t look like a healthy system.

@Shital_Katre good point, and it splits into two cases:

  • The check runs but fails (expired credential, Shopify error, SMTP down): both workflows have error outputs on every Shopify, Sheets and email node, so a failed run sends its own alert instead of looking quiet.
  • The check never runs at all (n8n down, workflow deactivated): nothing inside n8n can report that, because the thing that would alert is the thing that’s down. That needs a heartbeat from outside: the last node pings an external dead-man’s-switch monitor (any uptime/cron-monitoring service), which alerts you if the ping doesn’t arrive on schedule.

Logging the last successful run timestamp is useful for the second case too, as long as something outside n8n is the one reading it.

Fair reframe — “up to a day late, and ambiguous on zero-sale days” is sharper than my “blind”. I’ll steal your phrasing from now on.

One caution on the re-registration trick though: after deactivating/re-activating the trigger, do a quick webhookSubscriptions list check to confirm exactly one orders/create subscription exists for your callback URL. If the old subscription survived (happens when the token or the app behind it changed), both fire into the same workflow and every order processes twice. Dedupe by order ID at the top of the workflow, or delete the stale one first. And fully agree on the app-credential point — the health check has to run on the same custom app’s admin token as the trigger, otherwise the query returns an empty list and you get a false “all clear”.

@Shafique_Mumtaz good catch, and that one is nasty because nothing errors: orders just get processed twice. It’s why the health check I ended up building compares the subscription list against the topics you expect and flags three cases: missing, pointing at the wrong host, and more than one subscription for the same topic and URL. It also does a GET on each n8n webhook URL. n8n’s 404 message tells “listening” apart from “workflow inactive”, and a GET never runs the workflow. It only emails when something changes, so it can run every few hours without noise.

Agreed on deduping by the stable order ID at the top of the workflow anyway. That way a late webhook, a retry or a duplicate subscription can’t double-process an order.

@TrenoxaHQ That three-way split is the cleanest version of this so far — missing, wrong host, and duplicate. Dedupe alone fixes the double-processing, but it also hides the cause, so a duplicate subscription can just sit there unnoticed.

One detail I would add: put the order-ID check before any side effect (the sheet append, the email, any fulfillment step), and still count the duplicates you drop instead of silently discarding them. That way a late webhook or a retry is harmless, but a rising duplicate count still tells you a subscription needs cleaning up, rather than the health check being the only thing that notices.

The reconciliation approach is the important part here. A webhook workflow can only report failures it actually receives, so a separate check gives you a second source of evidence.

One case I’d test is an order that exists in Shopify and also reached the original workflow, but the Google Sheets write failed. The reconciliation process would correctly identify the missing log entry, although that doesn’t necessarily mean the order itself was never processed.

I’d be interested in how you distinguish “order was never processed” from “order was processed but the audit record is missing,” especially before triggering any recovery action.

@Shafique_Mumtaz agreed on both. Dedupe first, before the sheet, the email or anything else, and keep a count of the duplicates you drop. A count that keeps climbing tells you something upstream is wrong, while a silent dedupe just hides it.

@Ayub_Shah good question. The honest answer is that a reconciliation against the sheet can’t tell those two cases apart, and that’s why mine doesn’t replay anything. It flags “this order has no log row” and emails a person, who checks before acting. A missing row means missing evidence. It doesn’t prove the order was never handled.

If you want the check to make that call itself, two things help:

  1. Write the log row first, then update it. Make the first step after dedupe append a row with status received, and change it to done after the side effects. No row means the webhook never arrived. A row stuck on received means it arrived but failed partway, so you know where to look.
  2. Leave a marker on the order in Shopify. For example, a tag or metafield set as the workflow’s last step. Then the check can ask Shopify, the source of truth, instead of a sheet that can fail on its own. Order present, no marker: not processed. Marker present, no row: only the audit write failed, so just backfill the row.

With either one, recovery stops being a guess. I’d still keep a person in the loop for anything that sends email to a buyer.

@TrenoxaHQ and @Ayub_Shah — thanks, both points land.

@Ayub_Shah, your Sheets case is exactly where a simple missing-row check can mislead, so I would not let the reconciliation replay anything on a missing row alone. I like Trenoxa’s two-layer evidence here, and I would use it to split the missing-row cases before any recovery:

  • No log row and no marker on the order in Shopify: that is the closest to “never processed” — queue it for review, and only then replay, with the order-ID dedupe at the top.
  • A row stuck on received, never updated to done: it arrived but failed partway. Resume from the failed step rather than restarting the whole workflow, so you do not repeat a side effect that already happened.
  • Marker present in Shopify but no log row: only the audit write failed. Backfill the row and stop there — re-running the workflow would be the duplicate you were trying to avoid.

One extra guard I would add: make the marker write the last step, but do not treat it as absolute proof on its own, since that write can also fail. If the marker and the log disagree, keep a person in the loop, as Trenoxa said, and log the recovery decision separately so the next reconciliation run does not keep flagging the same order.

That way the sheet stops being the only source of truth, and “missing” stops automatically meaning “never processed.”

That split is the right one: “missing” and “never processed” aren’t the same thing.

I’d automate one more step: the marker-but-no-row case is safe to handle without a person, because nothing gets re-run. The daily check can add those rows back and only send the other two cases (no row + no marker, and stuck on received) to a person to review. That keeps the review list short enough that people actually read it.

Good point on logging the recovery decision too. Without it the same order gets flagged every morning, and people stop trusting the check.

To be honest, from my side I use the same thing for WooCommerce, but I just introduced a custom attribute where I have status open and switch it to closed . This enable me to not miss anything. This can be achieved also in Shopify, where you make sure that everything has been processed as expected.

@TrenoxaHQ yeah, that split is the way to keep it workable. If the marker-but-no-row case is the only one you auto-fix, the review list stays short enough that someone actually looks at it, which is kind of the whole point.

One small thing I’d add on that auto backfill: mark the row as backfilled rather than done when you add it, and log the decision with it. That way the next run knows it wasn’t a normal run, and you don’t lose the trail if something later looks off.

@SilentJMA same idea on your side, really — open/closed is basically that marker, just inside WooCommerce. In Shopify I’d do the same thing with a tag or metafield, as long as it’s set as the last step and not something an earlier step can set by accident.

1 Like

Really like that you kept recovery a human decision, that’s the part most “self-healing” versions get wrong. Two edge cases bit me when I ran almost this exact daily reconciliation, and both were around the query itself rather than the logic.

The first was window boundaries and timezone. created_at comes back in UTC, but “yesterday” usually means the shop’s local day, so a daily job keyed on a naive UTC midnight either misses or double-counts orders placed in the hour or two either side of that boundary. What fixed it for me was overlapping the window slightly, pulling a bit before and after the day I actually care about, and leaning on the order-ID dedupe to drop the overlap, rather than trusting the window to be exact. On a zero-sale day that overlap also gives the check something to compare against instead of going quiet.

The second was GraphQL cost throttling on the manual loop. Since the HTTP node’s pagination fails with the OAuth2 credential the way you described, the manual IF-loop back to the request is the right call, but on a busy shop that loop can trip the cost-based rate limit partway through and come back throttled rather than erroring cleanly. Reading extensions.cost.throttleStatus and waiting when currentlyAvailable drops low keeps a big backfill from silently truncating, which would otherwise show up as “missed” orders that were really just never fetched.

One smaller one: test orders. If the webhook workflow skips test:true orders but the reconciliation query doesn’t, those test orders show up as permanent “missed” and keep pinging the alert every morning. Mirroring the same filter on both sides stops the noise.

To your closing question, I reconcile against Shopify rather than trusting retries, same reasoning as yours: the only source that can’t lie about what happened is Shopify itself. And the subscription health check you ended up with, missing versus wrong host versus duplicate, plus the GET to tell “inactive” apart from “listening”, is the cleanest take in this whole thread. I’m stealing the three-way split.