The Payload Shape Changed and Nobody Sent You a 400
On September 16, 2026, a field in Shopify's Events payload quietly changed type. fields_changed used to be an array of strings. Now it is an object with three keys. If your handler ran fields_changed.map(...) or checked fields_changed.length, nothing threw a loud, obvious error the moment the new shape landed. It got undefined. Or a length of undefined. Or it iterated over object keys and produced garbage. And the whole time it kept returning 200 to Shopify, because your HTTP layer had no idea anything was wrong.
That is what makes webhook payload changes meaner than API changes. When a REST response changes shape, you find out on your next request, because you are the one calling. Here the sender is calling you, the delivery still succeeds, and the damage sits inside your parsing code where no status code can reach it.
Let me walk through exactly what Shopify changed, why the array-to-object move is defensible, and how to write receivers that do not fall over the next time a provider reshapes a field under you.
What Actually Changed on September 16
Four things landed at once, and they hit Shopify's Events system, the newer fine-grained event subscriptions, not the Classic Webhook topics like orders/create.
The headline change: fields_changed went from a flat array to an object. Before, you got a list of path strings describing what moved. After, you get added, updated, and removed arrays, so you can tell whether a resource or relationship was added, a value was updated, or something was removed, without making a follow-up API call to diff it yourself.
Second, parent triggers now need an explicit .* wildcard. A subscription to product.variants has to become product.variants.*. Leaf triggers like product.variants.price are untouched. Miss this and the subscription no longer matches what it used to.
Third, two delivery headers are gone: shopify-event-id and shopify-resource-id. If you were reading either one, that code now reads undefined.
Fourth, event subscriptions using the update action now require at least one trigger. An update subscription with no triggers is no longer valid.
None of these arrived as a broken connection or an HTTP error. They arrived as normal, successful deliveries carrying a shape your code was not written for.
Why fields_changed Went From Array to Object
The old flat array was honest but useless. It told you something under here changed and left you to work out what kind of change it was. A variant added to a product and a variant's price edited looked identical: two path strings in a list. To act on that correctly you often had to fetch the current object and rebuild the delta yourself.
The new object hands you the delta the sender already knew. Three buckets, added, updated, and removed, each holding the paths that belong to it. If you sync state into your own database, this is genuinely better. A removal can drive a delete. An addition can drive an insert. You stop round-tripping to the API just to figure out which kind of change you got. It is the same instinct behind thin events, only here the sender gives you more structure, not less.
So the change is good. The rollout is the problem.
The Array-to-Object Break Is the Nastiest Kind
A field that goes from a list to an object is the worst category of payload change, worse than a renamed key or a removed field, because your language will not always help you catch it.
// Worked before September 16, silently wrong after
const paths = event.fields_changed.map((p) => normalize(p))
Calling .map on an object throws, so at least that line fails loudly. But this one does not:
// Returns undefined after the change, no exception
const count = event.fields_changed.length
if (count > 0) reconcile(event)
An object has no length, so count is undefined, the comparison is false, and reconcile silently never runs. Your logs are clean. Your error rate is flat. The events you were supposed to process just evaporate into a branch that no longer fires. This is the same class of bug covered in when webhooks return 200 OK but nothing happens, and a type change is one of the most reliable ways to produce it.
The Two Headers You Were Probably Deduplicating On
shopify-event-id disappearing is not cosmetic. If you built idempotency around it, storing each event id and skipping ones you had already seen, that dedup key is now undefined on every delivery. Depending on how you wrote it, you either treat every event as brand new and reprocess duplicates, or you treat every event as already-seen because one undefined matched the last undefined you stored, and drop everything. Both are bad, and both look fine at the HTTP layer.
If your idempotency keys came from a header the provider can remove, they were never really yours. The durable fix is to derive an identity from something inside the payload that the provider guarantees, or to combine several stable fields into your own composite key. A header is metadata; treat it as a convenience, not a contract.
The Wildcard That Silently Unsubscribes You
The .* trigger change is the quietest of the four. Nothing in your receiver breaks. Instead, the subscription that used to match product.variants stops matching, and events simply stop arriving. There is no error, because from your side nothing happened, and nothing happened is indistinguishable from a genuinely quiet week.
This is why you cannot monitor webhooks only by watching for failures. A subscription that goes silent produces no failed deliveries to alert on. You need a heartbeat: an expectation that a given event type shows up at some minimum rate, and an alarm when the arrival count falls off a cliff. Absence is the signal, and absence is exactly what most monitoring misses.
Why Classic Webhooks Being Unaffected Is Cold Comfort
Shopify was careful to note that Classic Webhook subscriptions, the familiar orders/create and products/update topics, are not touched by this. If you are entirely on Classic Webhooks, none of the above applies to you today.
But today is the operative word. Events is the newer model, and providers move investment toward the newer model. That is where the breaking changes happen, because it is still evolving. Knowing which of a provider's two delivery systems you are on, and which one it is quietly steering you toward, is now part of the job.
This Is a Versionless Break, and That Is the Real Complaint
You can argue about arrays versus objects all day. The deeper issue is that a payload's shape changed on a date, for everyone on that system, without a per-request version you controlled. Compare that to a scheme where the payload version is pinned and you opt into the new shape when your parser is ready.
Good webhook versioning means a shape change ships as a new version, old and new run in parallel, and you migrate on your own clock. When a change instead lands on a fixed calendar date for the whole system, your only defense is to have read the changelog in time and shipped your parser before the date. That is a process control, not an engineering one, and process controls fail the week someone is on holiday.
Write Parsers That Survive a Shape Change
You cannot stop providers from reshaping fields. You can stop a reshape from taking down your handler. The move is to stop assuming a field's type and start checking it.
function readChangedPaths(fieldsChanged) {
if (Array.isArray(fieldsChanged)) {
// old flat-array shape: treat every path as an addition
return { added: fieldsChanged, updated: [], removed: [] }
}
if (fieldsChanged && typeof fieldsChanged === 'object') {
return {
added: fieldsChanged.added ?? [],
updated: fieldsChanged.updated ?? [],
removed: fieldsChanged.removed ?? [],
}
}
throw new UnexpectedPayloadError('fields_changed', fieldsChanged)
}
Two things matter here. The parser accepts both the old and the new shape, so it keeps working across the cutover instead of failing at midnight. And the final branch throws loudly on anything it does not recognize, so the next unexpected shape surfaces as an alert instead of a silent undefined. A schema validator at the edge of your handler does the same job more formally: validate the incoming payload against what you expect, and reject, visibly, what does not fit. Guessing is what turns a payload change into a silent outage.
Catch It in Monitoring, Not From a Customer
Every failure mode above shares a trait: the HTTP delivery succeeds. That means your uptime graph, your success rate, and your load balancer all say everything is fine while the actual work quietly stops. If you only alert on failed deliveries, you will learn about a payload change from a customer asking why their data is stale.
Instrument the layer below the response. Count how many events you fully processed versus merely acknowledged. Track the presence of the fields you depend on, and alert when a field you have always seen drops to zero percent of deliveries, which is a rename or a reshape caught in minutes. Watch per-event-type arrival rates so a subscription that goes silent trips an alarm. This is the difference between monitoring webhook health and monitoring your web server, and only one of them would have caught September 16 on the day.
A Migration Checklist That Covers the Edges
If you are on Shopify Events, the work is small but specific. Update every fields_changed reader to handle the object shape, ideally accepting both shapes during the transition. Append .* to every parent trigger and leave leaf triggers alone. Find every read of shopify-event-id and shopify-resource-id and replace it with an identity you derive yourself. Audit update subscriptions for at least one trigger. Then, and this is the part teams skip, add a test that feeds your handler the new payload shape so the next person cannot regress it.
The general lesson outlives this one change. Treat every provider payload as a contract that can be renegotiated without your signature. Parse defensively, key your idempotency on something you control, and read the changelogs for every delivery system you subscribe to, including the one you think you are not using.
Frequently asked questions
Does this Shopify change affect Classic Webhook topics like orders/create? No. Shopify scoped this to the Events system, the newer fine-grained subscription model, and stated that Classic Webhook subscriptions are unaffected. If you rely entirely on Classic topics, none of the four changes apply to you right now, but Events is where the provider is investing, so it is worth knowing which system each of your integrations actually uses.
Why is an array-to-object change more dangerous than a removed field? A removed field usually reads as null or undefined and often trips an existing null check. A type change slips through those checks: calling array methods on an object throws in obvious cases but returns undefined in the quiet ones, like reading a length that no longer exists. Undefined then flows into a conditional that silently stops firing, so the event is acknowledged with a 200 while the processing never happens.
I keyed idempotency on shopify-event-id and it is gone. What now? Derive an identity from stable fields inside the payload rather than from a header the provider can remove. Combine the resource identifier, the event type, and a timestamp or sequence value into your own composite key, then store and check that. A header is metadata the sender controls unilaterally, so anything you build on it can vanish on a changelog date.
How would I have caught this before a customer did? Monitor processing, not just delivery. Track how many events you fully handled versus merely returned 200 for, alert when a field you always see drops to zero percent presence, and watch per-event-type arrival rates so a subscription that goes silent raises an alarm. Failure-only alerting misses every one of these because the HTTP delivery still succeeds.
Should I always accept both the old and new payload shapes? During a migration window, yes. A parser that handles the old array and the new object keeps working across the cutover instead of breaking at the exact moment the change lands. Once you have confirmed only the new shape arrives for long enough, you can drop the old branch, but keep a loud error on anything unrecognized so the next reshape surfaces as an alert rather than a silent undefined.