Skip to content

Google Merchant API — operational notes

Operational gotchas for the Merchant API v1 integration (Merchant Center account 8613489, US + CA on one account). This is the “what bites you in production” companion to the build docs — it does not restate the architecture.

Build/design context lives in the task docs: doc/tasks/202606291024_MERCHANT_API_FEED_MIGRATION.md (product-feed cutover) and doc/tasks/202606301102_GOOGLE_MERCHANT_PROMOTIONS.md (promotions layer).

Code: Feed::Google::MerchantApi::Client, Edi::Google::{Orchestrator,ProductDataProcessor,Sender,PromotionDataProcessor,PromotionSender}.

Merchant Center composes each served product from two independent data sources, and they migrate independently:

Primary product data Local inventory
Carries title, price, availability, shipping, … per-store storeCode + quantity
Source today API pushproductInputs:insert, one primary API data source per feed label (US, CA), daily via EdiProductDataFlowWorker (02:15 CT) still a FILE/FETCH feedWY-US/CA Local Inventory, fetching /api/v1/:locale/google/local_inventory
Built by Feed::Google::MerchantApi::ProductInputBuilder Feed::Google::LocalInventoryGenerator via GoogleLocalInventoryFeedWorker (04/10/16/22 CT, US then CA at :15)

The local inventory source is supplementary: it only attaches per-store availability onto primary products that already have FREE_LOCAL_LISTINGS / LOCAL_INVENTORY_ADS enabled. The primary feed cannot carry per-store stock. You need both halves, and they are joined by offer id inside Merchant Center.

Migrating the primary source orphans local inventory

Section titled “Migrating the primary source orphans local inventory”

Symptom: products report ELIGIBLE_LIMITED with issue local_stores_lack_inventory, even though the local inventory feed contains them, is in stock, and uploaded without errors.

Cause: when the primary product data source is migrated or recreated (the FILE → per-country API cutover), the already-attached local inventory records are orphaned. They point at a data source that no longer owns those products. This is not a feed regression and not a destinations-config problem.

Do NOT “fix” it by disabling local destinations. That kills a working local program. During the 2026-06-30 reconciliation, US was serving 792 products correctly while CA showed 653 limited — same config, one half mid-migration.

Fix — force a re-fetch of the local inventory data source. No config change, no code change:

POST https://merchantapi.googleapis.com/datasources/v1/accounts/8613489/dataSources/<local-inventory-ds>:fetch

Auth is the same service account and https://www.googleapis.com/auth/content scope the client already uses (google.merchant.service_account in credentials; 1Password IT/Google-Merchant-API). Enumerate the data sources with Feed::Google::MerchantApi::Client#data_sources to find the local inventory name. ⚠️ Client has no fetch method — this is a one-off curl or bin/rails runner call, not a code path.

Result on 2026-06-30: CA went 653 ELIGIBLE_LIMITED → 0 after a single forced fetch.

Verify with localInventories, not the report

Section titled “Verify with localInventories, not the report”

The three surfaces settle at wildly different speeds:

Surface Lag
fileUpload status (itemsTotal, error count) seconds
inventories/v1/…/products/local~en~CA~<offer>/localInventories ~15 min
product_view reporting (item_issues, ELIGIBLE_LIMITED counts) hours

Right after a forced fetch, product_view still reported stale resolution: MERCHANT_ACTION. Don’t trust the immediate recount and don’t re-run the fix on the strength of it — watch a sample offer’s localInventories flip 404 → 200 instead.

Not to be confused with the other ELIGIBLE_LIMITED

Section titled “Not to be confused with the other ELIGIBLE_LIMITED”

The migration task doc records a different root cause for ELIGIBLE_LIMITED at 202606291024_MERCHANT_API_FEED_MIGRATION.md:174: a single refurbished SKU (tws2-ldn11ph-btk) that we correctly exclude from the feed but which Merchant Center still had cached from the old FETCH feed (Google retains fetched products ~30 days). Its issue string was missing_shipping_mismatch_of_shipping_method_and_offer_currency, it affected 1 product, and the resolution was to wait for expiry — nothing to fetch.

Tell them apart by the issue code and the blast radius:

Orphaned local inventory Stale MC cache
Issue local_stores_lack_inventory missing_shipping_mismatch_of_shipping_method_and_offer_currency
Scale hundreds, one whole feed label 1–2 products
In google_feeds? yes no (correctly excluded)
Fix forced dataSources…:fetch nothing; expires in ~30 days

A third variant, also currency-mismatch, comes from using one unscoped API data source for both countries — see the “USE TWO PER-COUNTRY API SOURCES” warning in the migration doc.

Anomaly monitoring (disapprovals / item issues)

Section titled “Anomaly monitoring (disapprovals / item issues)”

GoogleMerchantReportSyncWorker (05:00 CT) pulls the product_view report (Feed::Google::MerchantApi::ReportPuller) onto each catalog_item.google_report; ListingIssueSyncWorker (05:30 CT) reconciles it into ListingIssue rows via ListingIssues::GoogleAdapter, same as Amazon/Walmart/Wayfair. Check the CRM Listing Issues dashboard (provider=google filter) or the daily retailer-compliance email — no separate Google-specific dashboard exists or is needed. See the migration doc’s Phase 4 for the build.

Both of these were learned the hard way on the first live promo push (2026-06-30). Both are now closed in code; the code is the authority, and these notes exist only so the error strings are searchable.

  • Effective window must be strictly under 6 months. Google measures it in absolute UTC time, so a calendar “6 months minus a day” plus end_of_day plus a DST offset change still tips over and returns HTTP 400 INVALID_PROMOTION_DURATION_EXCEEDS_LIMIT. The ceiling is computed as an absolute Time with a one-week buffer in PromotionBuilder#effective_period — read that method and its comment before touching the date maths.

  • The discount must be additional at checkout. Google test-crawls the landing page, applies the promo, and rejects with “No or a lower discount found” if there is no further discount — i.e. if the discount is already baked into the feed salePrice. promo_tracking / auto_apply coupons are exactly that case, so they can never be syndicated. Enforced by Coupon#syndicate_to_google_eligibility (app/models/coupon.rb), which also requires a public tier-3 customer-entered code.

The practical consequence: WarmlyYours’ sale-page coupons are all promo_tracking, so essentially none qualify — and that’s correct. Those discounts already serve through the product feed’s salePrice, which is the right channel. Google Promotions only adds value for genuine checkout-additional / code offers.

  • v1beta is dead (HTTP 410 since 2026-02-28). Use merchantapi.googleapis.com/{datasources,products,promotions,inventories,reports}/v1.
  • Developer registration is required on top of adding the service account as a Merchant Center user — see the migration doc’s “developer registration is REQUIRED” section.
  • Content API for Shopping v2.1 sunsets 2026-08-18; do not build on it.