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}.

The two halves of a product

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

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

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

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)

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.

Miscellaneous

  • 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.