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