Products/update fires with a stale product-level updated_at for variant image assignment (variants[].updated_at is fresh) — silently breaks timestamp-gated sync

While migrating our app (Supergrid by Depict) to the 2026-07 collections model we found a products/update timestamp inconsistency that silently breaks the widely-recommended “ignore payloads with an older-or-equal updated_at” sync pattern.

What happens (dev store, 2026-07, reproduced via webhook capture):

Assigning images to a product’s variants one at a time (admin UI) fires products/update per edit — good — but every payload carries the same product-level updated_at (from the first edit), while the payload’s variants[].updated_at tick correctly per edit:

Delivery (UTC) payload updated_at max variants[].updated_at
21:51:44 21:51:42 21:51:42
21:51:59 21:51:42 21:51:57
21:52:29 21:51:42 21:52:27
21:53:01 21:51:42 21:53:00

Admin API product.updatedAt also stays frozen at 21:51:42 through all of this, and only catches up ~13 minutes later (22:05:58 — async media pipeline?), which then fires one more webhook.

Why it matters: the standard out-of-order-webhook defense — persist only when the payload’s updated_at is strictly newer than what’s stored — discards every one of those payloads after the first. Any app following that guidance keeps stale variant data (images, in our case) with no later signal to fix it until some unrelated edit bumps the product clock. Other variant edits behave correctly (a variant price change bumps both the variant and product updated_at — verified from the same capture), so this looks specific to variant-image assignment / media linking.

Possibly related known gaps: removing a variant’s image fires no products/update at all (older report), and variant metafield changes don’t fire it either.

Asks:

  1. Bump product-level updated_at whenever a products/update webhook is emitted — a payload should never carry content newer than its own timestamp.
  2. Until then, document that timestamp-gated consumers must key on max(product.updated_at, ...variants[].updated_at) — that’s the workaround we shipped.

Happy to share the store, product id, and webhook capture privately.

Hi @depict_daniel, thanks for raising this here!

I tested three equivalent assignments on API version 2026-07 using productVariantsBulkUpdate with mediaId. Each products/update delivery carried a fresh product updated_at that matched the newest variant timestamp, so I could not reproduce the stale parent timestamp.

I’ll follow up by DM for the store, product, and delivery details needed to compare the exact path so we can take a closer look for you - thanks again!

Thanks @Donal-Shopify — your test not reproducing turned out to be the
key clue, and we’ve now isolated it (full store/product/header details
sent to you by DM).

Your path is genuinely clean: productVariantsBulkUpdate with an
existing mediaId bumps the parent updated_at correctly — we
see that too. The trigger is assigning newly created media to a
variant in the Admin UI: create a new image in the moment (we used
Shopify’s built-in AI image generation; a fresh upload behaves the
same) → set it as the variant image → save.

We re-ran the sequence today capturing every delivery on 2026-07 and
2025-10 simultaneously
(two parallel webhook endpoints, one edit
sequence): identical results on both, so it’s not version-specific.
And this run was starker than my original report — the product-level
updated_at in every products/update payload was four days
stale
(frozen at the product’s last unrelated edit) while each
payload’s variants[].updated_at tracked the saves to the second, and
no catch-up bump ever arrived (the ~13-minute catch-up in my original
capture appears not to be guaranteed). A live Admin API read of
product.updatedAt half an hour later still showed the stale value.

For anyone doing timestamp-gated sync in the meantime: don’t gate on
the payload’s product-level updated_at alone — key on
max(product.updated_at, ...variants[].updated_at). That’s the
workaround we shipped, and it restores a monotonic clock for this
case.

We are seeing similar behaviour starting approximately last week.

Steps to reproduce:

  1. Create a product with variants and set a barcode on one variant
  2. Open the variant detail page in Shopify admin and update the barcode
  3. Save the changes
  4. Request GET /admin/store/{store}/products/{product_id}.json
  5. Request GET /admin/store/{store}/products/{product_id}/variants/{variant_id}.json

Expected behavior: Both endpoints return the same updated_at for the variant, and the same updated_atfor the product.

Actual behavior:

  • /products/{id}/variants/{id}.json → new updated_at – CORRECT
  • /products/{id}.json → old updated_atfor both the variant and the product – INCORRECT

Please note this is reproducible on any store and API (graph, rest and version) but the behavior depends on where & how the update is triggered. So, sharing the specific path

One small addition - when the barcode is set for the first time (previous value == null) then it works correctly, when you update it, the behavior I described above can be observed

Hi @depict_daniel and @Daniel_M , thanks for the detailed reports and for sending the repro details privately.

The product team is investigating the barcode-update behavior. I reproduced it independently on a test store via productVariantsBulkUpdate (a barcode value-to-value change advances variants[].updated_at but not the parent product.updated_at, while a first-time set touches both), which matches @Daniel_M’s observation exactly. I’ll share more once the team has weighed in.

The media-assignment path @depict_daniel described is the same symptom shape (variant timestamp fresh, parent product timestamp stale, products/update still fires) but a different trigger. The public API media-assignment path with an existing mediaId does not reproduce it, so it appears specific to assigning newly-created media in the admin. I’ve raised it with the same product team for triage and shared the store, product, and full delivery capture you sent via DM.

I’ll follow up here when I have more to share from the product team - thanks for your help on this!

Hey folks, following up here as promised. The product team has shipped a fix for this, and I’ve verified it on a test store today (on 2026-07). The parent product updated_at now advances correctly when you change a variant barcode from one value to another. The same productVariantsBulkUpdate sequence that previously left the product timestamp frozen now bumps it, verified across both GraphQL and the REST product endpoint. First-time barcode sets (null to a value) still work as they did before, so no regression there.

@Daniel_M, if you re-test your barcode update path, you should see the product updated_at advance alongside the variant updated_at now.

@depict_daniel, the fix is expected to cover your media-assignment trigger as well. If you can re-run your original capture (assigning a freshly created image to a variant in the admin and recording the products/update deliveries), that would confirm whether your specific path is resolved too.

Thanks for flagging this with us!

Confirmed fixed on our path too — thanks @Donal-Shopify, and thanks
for pushing it through.

I re-ran it today on the same store and product, with a control so the
result couldn’t be ambiguous:

Control (the barcode path you verified)
productVariantsBulkUpdate changing a variant barcode from one value
to another, at 14:21:17Z. The parent product.updated_at moved from
2026-07-18T11:49:32Z to 2026-07-29T14:21:10Z. So the fix is
definitely live on this store, which makes the next result
interpretable.

Our path (Admin UI, newly created media) — generated a brand-new
image with Shopify’s AI image generation, assigned it as a variant’s
image, saved at 14:27:13Z. The resulting products/update delivery
(webhook API version 2026-07):

x-shopify-triggered-at: 2026-07-29T14:27:18Z
body product.updated_at: 2026-07-29T14:27:18Z   <- fresh, was frozen before
body variants[Dawn].updated_at: 2026-07-29T14:27:18Z

Parent and variant clocks now advance in lockstep, and the same held
for both barcode deliveries. Previously this exact path left the
parent frozen across every delivery.

One observation others may want to know: the fix is not
retroactive.
On this product the parent updated_at had been stuck
at 2026-07-18T11:49:32Z for 11 days — through five variant-image
assignments on 07-22 — and only advanced when a post-fix edit touched
it today. So if you gated ingestion on the parent timestamp during the
affected window, that data is still stale on your side and needs a
reconcile pass; the fix alone won’t heal it.

We’re keeping our max(product.updated_at, ...variants[].updated_at)
key regardless — it costs nothing and is a correct monotonic ordering
key either way. Appreciate the quick turnaround on this one.

Thanks @Donal-Shopify , we have retested it as well – the part we were concerned about is now fixed, appreciate it.