Skip to content

Developer notes

Technical reference for developers who customize Google Merchant API Sync.

Item Value
Add-on id kv_google_merchant
Function prefix fn_kv_google_merchant_
Language variables kv_google_merchant.*
CSS scope .kv-google-merchant
PHP namespace Tygh\Addons\KvGoogleMerchant

The Content API for Shopping reached its sunset on 2026-08-18; requests without an approved extension fail intermittently with HTTP 410 from 2026-09-01 and all endpoints are decommissioned in early 2027 (date may change). The replacement is the Merchant API. Source: https://developers.google.com/shopping-content/guides/sunset (read 2026-11-07).

File feeds (the add-ons Product Feed Pro for Google Merchant Center and Multichannel Product Feeds) are not part of that sunset and are not replaced. This add-on is the direct API channel next to them: it pushes changes within minutes, removes products at once, and reads back the Google status and item level issues. It can use the Google category mapping of Product Feed Pro as a read-only fallback.

Class Role
Client\BaseClient HTTP (cURL, TLS verification, timeouts), JSON, error mapping, masked logging, one forced re-login on 401
Client\Api Every Merchant API call and the Google sign-in (refresh token grant, service account JWT), see the endpoint table
Mapper Pure translation: ProductInput, price micros, GTIN check, status and issues from products.list, data source body
Items Product selection per target (storefront, vendors, categories, stock) and the normalized item (price in the target currency plus tax, availability, images, features)
Mapping Attribute sources (auto / field / feature / fixed / none) and Google product category per category, per storefront
Targets Targets (= API data sources: language + feed label + country), duplicate guard, data source create / adopt
ProductMap One row per target and product: offer id (fixed before the first call), state Q/S/E/R/X, payload hash, Google status, issues
Sync push (insert / delete), pollStatus (products.list), seed (full sync slices)
Jobs, Queue, JobException Jobs push, full_sync, poll_status; back-off 5 min / 15 min / 1 h / 3 h / 12 h; 429 uses the wait the API asks for (at least 2 min)
Accounts, Secret Credentials sealed with AES-256-GCM
Config, Access Settings; the account is the store / marketplace owner’s, vendors have no access
Log Event log with masking (tokens, client secret, private key, assertion), filters, CSV, retention

Base: https://merchantapi.googleapis.com. OAuth scope for all calls: https://www.googleapis.com/auth/content. Status of each entry: verified = read on the official reference page on 2026-11-07, flagged = not confirmed on an official page (see the note).

Purpose Call Source Status
Insert / replace an offer POST /products/v1/accounts/{account}/productInputs:insert?dataSource=accounts/{account}/dataSources/{ds} (only API data sources) reference products_v1/accounts.productInputs/insert verified
Delete an offer DELETE /products/v1/accounts/{account}/productInputs/{language}~{feedLabel}~{offerId}?dataSource=... (empty body, {} answer) reference .../productInputs/delete verified
Product identity contentLanguage~feedLabel~offerId. In the get and delete paths the segment is an UNPADDED base64url string when any part holds /, % or ~ (en~US~sku/123 is ZW5-VVN-c2t1LzEyMw); the add-on sends the plain form only for parts made of A-Z a-z 0-9 . _ - reference productInputs/delete (“This encoding MUST be used if any part of the product identifier contains characters such as /, %, or ~”) verified
Same identity again = replace insert with the same language, label, offer id and data source replaces the entry (this is why a retry after a timeout cannot duplicate) guide products/add-manage verified
Refresh every 30 days “Refresh products at least every 30 days to prevent expiration”: offers are re-sent after 25 days even when unchanged guide products/add-manage verified
Read processed products GET /products/v1/accounts/{account}/products (pageSize max 1000, default 25; pageToken; answer products[], nextPageToken) reference .../products/list verified
Read one GET /products/v1/accounts/{account}/products/{language}~{feedLabel}~{offerId} reference .../products/get verified
Status and issues productStatus.destinationStatuses[] {reportingContext, approvedCountries[], pendingCountries[], disapprovedCountries[]}, itemLevelIssues[] {code, severity NOT_IMPACTED/DEMOTED/DISAPPROVED, attribute, description, detail, documentation, applicableCountries[]} reference products_v1/accounts.products (ProductStatus) verified
Create data source POST /datasources/v1/accounts/{account}/dataSources, body DataSource with displayName and primaryProductDataSource {feedLabel, contentLanguage, countries[]} reference datasources_v1/accounts.dataSources/create verified (DataSource.input is output only; “Only generic data sources can be created through the API”; the guide says products can only be inserted into data sources of type API, so adoption by name also requires input = API and the same feed label and language)
List data sources GET /datasources/v1/accounts/{account}/dataSources (pageSize, pageToken, dataSources[], nextPageToken) reference datasources_v1/accounts.dataSources lists the method list; the path follows the create path verified (GET https://merchantapi.googleapis.com/datasources/v1/{parent=accounts/*}/dataSources, pageSize max 1000, pageToken), used as the connection test and to adopt a data source after a timeout
Price {amountMicros: "15990000", currencyCode: "USD"}; amountMicros is a string, 1 unit = 1,000,000 micros reference Shared.Types/Price, guide example verified
Attributes title, description, link, imageLink, additionalImageLinks[], availability (IN_STOCK, OUT_OF_STOCK, PREORDER, LIMITED_AVAILABILITY, BACKORDER), condition (NEW, USED, REFURBISHED), price, salePrice, gtins[] (up to 10), mpn, brand, googleProductCategory, productTypes[], identifierExists, shipping[], itemGroupId, color, size, shippingWeight {value, unit}, gender (MALE, FEMALE, UNISEX), ageGroup (ADULT, KIDS, TODDLER, INFANT, NEWBORN) reference products_v1/ProductAttributes verified
Shipping object shipping[] {price, country}; none of the Shipping fields is required (speeds need both maxHandlingTime and maxTransitTime, which are not sent) reference products_v1/ProductAttributes (Shipping) verified
Tax no taxes attribute is sent; VAT is added to the price by the target’s “Add tax” percentage when needed v1 ProductAttributes has no taxes field verified (intentional)
Title / description limits 150 / 5000 characters Google product data specification (support.google.com/merchants/answer/7052112): title 150, description 5000, id 50, mpn 70, brand 70, color 100, size 100, item_group_id 50, product_type 750, up to 10 additional images, 2000 per URL verified
Authentication OAuth 2.0 (web server flow: https://accounts.google.com/o/oauth2/v2/auth, access_type=offline, prompt=consent, state; token and refresh at https://oauth2.googleapis.com/token) or a service account (JWT RS256, claims iss/scope/aud/iat/exp<=1 h, grant urn:ietf:params:oauth:grant-type:jwt-bearer). API keys are not supported. guides authorization/overview, authorization/access-your-account; identity guides oauth2/web-server, oauth2/service-account verified
Service account access The service account address is added as a user in Merchant Center (Settings, Access and services) guide authorization/access-your-account verified
Developer registration MANDATORY, once per Cloud project: POST /accounts/v1/accounts/{account}/developerRegistration:registerGcp, optional body {developerEmail}; answer DeveloperRegistration {name, gcpIds[]}. Unregistered projects get 401 UNAUTHENTICATED on every other call; calls work about 5 minutes after registering. Needs a production account with a verified homepage, ADMIN access of the caller; test accounts are not eligible; ALREADY_REGISTERED when the project is tied to another account. Status: GET /accounts/v1/accounts/{account}/developerRegistration guide quickstart/registration, reference accounts_v1/accounts.developerRegistration verified; called automatically after the first connect and from the Connection page
Availability date availabilityDate (ISO 8601 / RFC 3339 UTC) is sent for PREORDER (required by the specification) and for BACKORDER when a future date is known; a BACKORDER without a date is sent as OUT_OF_STOCK unless the setting says otherwise data specification availability_date, ProductAttributes verified
Item group itemGroupId = parent product id for variation products ProductAttributes, specification (item_group_id) verified
OAuth hardening state (random, bound to the session and the database) and PKCE (code_challenge S256, code_verifier on the exchange) identity guide oauth2/web-server verified
Quotas HTTP 429 RESOURCE_EXHAUSTED, quota/request_rate_too_high (per minute, rolling) and quota/daily_limit_exceeded (daily, resets 12:00 UTC); retry with exponential back-off; spread calls guide quotas-limits/quotas verified
Batching The Merchant API has no custom batch. Requests are sent one by one; the add-on stops at the first 429 and the job retries later guide compatibility/refactor-concurrent-requests verified

Not in version 1.0 (and why): notifications / push subscriptions (status is polled instead, every 6 hours by default), local and regional inventories (those are for local stores and regional pricing; online price and stock go through productInputs), promotions, product reviews, accounts and users management, supplemental data sources. They can be added later without changing the data model.

  • Identity. Offer id = product id (or product code, setting) stored in the map row BEFORE any call. The identity is contentLanguage~feedLabel~offerId plus the data source.
  • Idempotent. A timeout leaves the row queued; the identical insert is sent again and replaces the same offer. A data source is looked up by its display name before it is created, so a timeout during creation does not make a second one. A delete that answers 404 counts as done.
  • Change detection. The ProductInput is hashed; an unchanged product is not sent (but is refreshed after 25 days).
  • Triggers. update_product_post (any save, including price, status, category), update_product_amount_post (stock moves from orders), delete_product_post. A daily full check re-evaluates every product (no API calls for unchanged ones) and catches changes made behind the hooks (imports, SQL).
  • Eligibility. Active products, not downloadable-only types, target filters (storefront, vendors, categories, out of stock). Products that leave the selection are deleted at Google when “Remove disabled products from Google” is on.
  • Errors. HTTP 400/404/409/422 from the PRODUCT call belong to one product (state E with the message, the run continues). A refused LOGIN (token endpoint 400 invalid_grant / invalid_client, 401) is an AuthException: never a product error, the job stops as permanent and the Connection page shows the failure. 401/403 from the API stop the job as permanent. A 400/404 that names the data source (the merchant deleted it) clears the stored data source name, resets the rows and retries in a minute (the source is created again). 429 and 5xx/network stop the run and retry with back-off; quota/daily_limit_exceeded waits until 12:00 UTC, and no new push job is queued while a back-off runs.
  • Links. Always https on the target’s host (Ultimate: the storefront of the target; otherwise the Storefront URL setting; otherwise the default storefront), never dependent on the cron context; &currency=XXX is added when the target currency is not the primary one.
  • Removal queue. kv_google_merchant_removals holds offers that must leave Google although no map row points at them (target deleted, identity changed, “Remove all”): data source and identity as they were sent. The cleanup job works it off; 404 or a vanished data source count as done. It also runs when the licence is off.
  • Lost updates. Every map row has a version bumped on each change; a push that was in flight marks the row synced only when the version is unchanged.
Table Purpose
?:kv_google_merchant_accounts Connection (secrets sealed), last check, token cache
?:kv_google_merchant_targets Targets / data sources
?:kv_google_merchant_product_map Offers per target
?:kv_google_merchant_attr_map, ?:kv_google_merchant_category_map Mapping per storefront
?:kv_google_merchant_jobs, ?:kv_google_merchant_log Queue, log
?:kv_google_merchant_removals Offers waiting to be deleted at Google

PHP: update_product_post, delete_product_post, update_product_amount_post (init.php); init_templater_post only while an upgrade synchronization is pending. No template hooks in the storefront: the add-on prints nothing there (full page cache safe).

The base URL can be overridden for tests in config.local.php (development only); the same address then also serves /token and /auth:

$config['kv_google_merchant_base_url'] = 'http://127.0.0.1:11830';

node tests/mock-server/server.js 11830 starts a stand-in that follows the reference (validates the ProductInput, replaces by identity, 404 on delete, paging, Google error bodies, OAuth and JWT grants); node tests/e2e/e2e.mjs --store ... --db ... --root ... runs the HTTP end-to-end test; php tests/unit.php runs the unit tests (fixtures in tests/fixtures, modeled on the official examples).

Admin templates live in design/backend/templates/addons/kv_google_merchant/. Do not edit them in a store; they are replaced on update.