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 push — productInputs:insert, one primary API data source per feed label (US, CA), daily via EdiProductDataFlowWorker (02:15 CT) |
still a FILE/FETCH feed — WY-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" plusend_of_dayplus
a DST offset change still tips over and returns HTTP 400
INVALID_PROMOTION_DURATION_EXCEEDS_LIMIT. The ceiling is computed as an
absoluteTimewith 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 feedsalePrice.promo_tracking/auto_applycoupons 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.