Google Merchant Center Module — Admin
HTTP surface for syncing eligible product variants to a Google Merchant Center account via the Merchant API v1 (the canonical successor to the deprecated Content API for…
HTTP surface for syncing eligible product variants to a Google Merchant Center account via the Merchant API v1 (the canonical successor to the deprecated Content API for Shopping). Covers OAuth lifecycle, data source pick/create, configurable feed settings, bootstrap, per-row item visibility + management, and bulk re-enqueue.
Source:
api-modules/catalog-google-merchant/src/controllers/admin-google-merchant-*.controller.ts. Registered viaCatalogGoogleMerchantModule.forRoot()inapps/api/src/app.module.ts. Removing that line disables the plugin entirely — listeners stop firing, the BullMQ drain processor never registers, and these admin endpoints disappear from the OpenAPI spec.For one-time credential acquisition (Google Cloud project + OAuth client + Merchant Center prerequisites), see
docs/google-merchant/setup.md.
Conventions
Authentication
All endpoints require a Better-Auth admin session and a role granting the matching googleMerchant:* permission. The OAuth callback (GET /admin/google-merchant/oauth/callback) is the single exception — it's the redirect target Google sends the operator's browser to, and is protected by an opaque, single-use, Redis-backed state nonce instead.
| Endpoint group | Permission |
|---|---|
GET /status, GET /errors, GET /items, GET /items/:variantId, GET /items/:variantId/google-status, GET /data-sources | googleMerchant: view |
GET /oauth/start, DELETE /oauth, POST /register-developer, POST /bootstrap, POST /data-sources, POST /items/:variantId/resync, POST /items/:variantId/delete-from-google, POST /items/bulk/* | googleMerchant: manage |
GET /oauth/callback | none — CSRF via state nonce |
Response envelope
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, pagination */ }
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR, google_merchant_misconfigured, google_merchant_oauth_state_invalid, google_merchant_oauth_exchange_failed |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND (variant not in catalog) |
| 500 | INTERNAL_SERVER_ERROR (Merchant API call failed, GCP not registered, quota exceeded, etc.) |
Currency
All prices in the catalog are stored as integer subunits (paise / cents). The mapper converts to Merchant API price micros at the output boundary — subunits × 10_000 = amountMicros, with the configured currency setting stamped as currencyCode. There is no FX conversion; the storefront pricing currency must match google_merchant.currency.
Async consistency
Merchant API splits one product into two resources:
productInputs— what we write. Immediately consistent.products— the merged/processed view Merchant Center surfaces. Eventually consistent (~2–5 minutes after an insert).
POST /resync / POST /delete-from-google reflect on the productInputs side instantly (verified by our own sync_state rows) but the GET /items/:variantId/google-status probe (which reads the processed side) may report found: false for several minutes after a fresh push. This is normal.
Domain types
ConnectionStatus
type ConnectionStatus = {
connected: boolean;
accountId: string | null;
connectedAt: string | null; // ISO
scope: string | null; // OAuth scope granted
syncEnabled: boolean;
configuration: {
oauthClient: "configured" | "missing";
feed: "configured" | "missing";
missingKeys: string[]; // setting keys still blank
};
merchantAccount: { // null when not connected / unreachable
merchantId: string;
name: string | null;
websiteUrl: string | null;
reachable: boolean; // false = OAuth grant lacks MC access
} | null;
dataSource: { // null when data_source_id blank
dataSourceId: string;
name: string; // resource name
} | null;
counts: {
synced: number;
pending: number;
failed: number;
skipped: number;
deleted: number;
outboxPending: number;
};
};ItemSummary / ItemDetail
type SyncStatus =
| "synced"
| "pending"
| "failed"
| "skipped"
| "deleted"
| "never_synced"; // synthesized — no sync_state row yet
type ItemSummary = {
variantId: string;
productId: string;
productTitle: string;
productSlug: string;
productStatus: string; // "draft" | "active" | "archived"
productVisibility: string; // "public" | "private"
sku: string | null;
price: number | null; // subunits
thumbnail: string | null; // S3 key (not absolute URL)
syncStatus: SyncStatus;
googleProductId: string | null;
lastPushedAt: string | null;
lastError: string | null;
attempts: number;
};
type ItemDetail = ItemSummary's component snapshots + {
variant: { id, sku, price, specialPrice, thumbnail };
product: { id, title, slug, status, visibility, thumbnail };
inventory: { trackInventory, quantityOnHand, reservedQuantity, allowBackorder } | null;
syncState: { status, googleProductId, lastPushedHash, lastPushedAt, lastError, attempts } | null;
eligibility: { eligible: boolean; reason: string | null };
mappedProductInput: Record<string, unknown> | null; // live mapper preview
};DataSourceSummary
type DataSourceSummary = {
id: string; // numeric id portion of the resource name
name: string; // "accounts/{merchantId}/dataSources/{id}"
displayName: string;
input: "API" | "FILE" | "UI" | null; // only API-typed sources are valid push targets
isPrimary: boolean; // only `primaryProductDataSource` accepts our inserts
contentLanguage: string | null;
feedLabel: string | null;
};ItemGoogleStatus
The shape returned by GET /items/:variantId/google-status — a direct probe of Merchant API accounts.products.get:
type ItemGoogleStatus = {
found: boolean;
productName: string | null;
productStatus: {
googleExpirationDate: string | null;
creationDate: string | null;
lastUpdateDate: string | null;
} | null;
issues: Array<{ // async validation issues
code: string | null;
attribute: string | null;
reportingContext: string | null;
description: string | null;
detail: string | null;
severity: string | null;
documentation: string | null;
resolution: string | null;
}>;
destinationStatuses: Array<{ // per-destination approval state
reportingContext: string | null;
approvedCountries: string[];
pendingCountries: string[];
disapprovedCountries: string[];
}>;
};Endpoints
Base path: /admin/google-merchant.
OAuth lifecycle
GET /oauth/start — Build the consent URL
Required permission: googleMerchant: manage.
Generates a fresh CSRF state nonce, stashes it in Redis with a 10-minute TTL, and returns a Google consent URL with access_type=offline&prompt=consent so we always get back a refresh token.
Response 200
{ "data": { "authUrl": "https://accounts.google.com/o/oauth2/v2/auth?..." } }Errors
| Status | Code | When |
|---|---|---|
| 400 | google_merchant_misconfigured | client_id or client_secret blank |
GET /oauth/callback — Google redirect target
No auth guard — Google redirects the operator's browser here directly. CSRF defense is the state nonce, verified against Redis.
Query
| Name | Notes |
|---|---|
code | Required on success; OAuth authorization code |
state | Required on success; nonce from /oauth/start |
error, error_description | Set when the operator denied consent |
On success: exchanges code for tokens via google.auth.OAuth2.getToken, persists in integration_credentials (provider google_merchant), and auto-runs accounts.developerRegistration.registerGcp so the first Merchant API call doesn't trip on "GCP project not registered". Then either redirects to ${PUBLIC_ADMIN_URL}/settings/google-merchant?connected=1 or returns { connected: true }.
Errors
| Status | Code | When |
|---|---|---|
| 400 | google_merchant_oauth_state_invalid | State missing / expired / replayed |
| 400 | google_merchant_oauth_exchange_failed | Google refused the code; description includes Google's reason |
| 400 | provider error | When ?error=… was set by Google (e.g. user denied) |
DELETE /oauth — Disconnect
Required permission: googleMerchant: manage. Deletes the integration_credentials row. The sync worker will skip every subsequent drain with Integration not connected. Existing rows in google_merchant_sync_state are kept (the operator may reconnect with the same MCA + data source; we don't want to lose the dedup hashes).
Response 200
{ "data": { "disconnected": true } }Account-level setup
POST /register-developer — Register the GCP project
Required permission: googleMerchant: manage.
Calls accounts.developerRegistration.registerGcp against accounts/{merchantId}. Already done automatically inside /oauth/callback; this endpoint is for repair scenarios (e.g. if the operator reconnects with a different GCP project, or auto-registration failed silently). Idempotent — an already-registered project returns 200 with alreadyRegistered: true (detected via gRPC ALREADY_EXISTS or HTTP 409).
Response 200
{
"data": {
"registered": true,
"alreadyRegistered": false,
"message": "GCP project registered with Merchant Center"
}
}GET /data-sources — List data sources
Required permission: googleMerchant: view. Lists every data source visible to the connected Google account in this Merchant Center. Only entries with input: "API" and isPrimary: true are valid sync targets.
Response 200 — array of DataSourceSummary.
POST /data-sources — Create a primary API data source
Required permission: googleMerchant: manage.
Body
{
"displayName": "Supercommerce API feed", // 1..80 chars
"contentLanguage": "en", // optional; defaults to settings.language
"feedLabel": "IN", // optional; A-Z 0-9 _ -; defaults to settings.country
"countries": ["IN"] // optional; up to 10 ISO 3166-1 alpha-2 codes
}Response 201 — single DataSourceSummary. The numeric id is what the operator writes back to admin.google_merchant.data_source_id.
Lifecycle + visibility
GET /status — Connection + config + counts
Required permission: googleMerchant: view. Single source of truth for the admin UI's "Google Merchant" page — combines connection state, configuration completeness, a live Merchant Center account probe, the configured data source, and the sync_state aggregate counts.
Response 200 — ConnectionStatus.
POST /bootstrap — Push every eligible variant
Required permission: googleMerchant: manage.
Streams every variant whose parent product is status=active, visibility=public, and not soft-deleted (and whose own deletedAt is null) into the outbox as upsert intents, in 500-row chunks. The repeating drain picks them up on the next tick. Hash dedup means rows that already match Google's stored copy are no-ops; only changed rows actually hit the Content API.
Response 202
{ "data": { "enqueuedVariants": 444 } }GET /errors — Paginated failed rows
Required permission: googleMerchant: view.
Query
| Name | Type | Default |
|---|---|---|
page | int >= 1 | 1 |
limit | int 1..200 | 50 |
Response 200 — paginated envelope of failed rows:
type FailedItem = {
variantId: string;
googleProductId: string | null;
attempts: number;
lastError: string | null;
lastPushedAt: string | null;
updatedAt: string;
};Items management
GET /items — Paginated catalog × sync_state list
Required permission: googleMerchant: view.
LEFT-joins product_variant + product with google_merchant_sync_state; rows with no sync_state row are synthesized as syncStatus: "never_synced".
Query
| Name | Type | Notes |
|---|---|---|
page | int >= 1 | Default 1 |
limit | int 1..100 | Default 50 |
status | SyncStatus? | synced / pending / failed / skipped / deleted / never_synced |
search | string? | Case-insensitive LIKE on variant.id / variant.sku / product.title / product.slug. LIKE meta-characters are escaped. |
eligibleOnly | boolean? | When true, restricts to variants whose parent product is active+public+not-deleted |
Ordered by lastPushedAt DESC then variantId ASC.
Response 200 — paginated envelope of ItemSummary[].
GET /items/:variantId — Full detail + mapper preview
Required permission: googleMerchant: view.
Returns the variant + product + inventory snapshot, the persisted sync_state, an eligibility verdict, and a live preview of the ProductInput the mapper would push right now. The preview is invaluable for debugging — it's the exact payload the next drain would send.
Response 200 — ItemDetail.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Variant not in catalog |
GET /items/:variantId/google-status — Live Merchant API probe
Required permission: googleMerchant: view.
Calls accounts.products.get with name accounts/{merchantId}/products/{language}~{feedLabel}~{offerId} and surfaces:
- The processed-product timestamps (
creationDate,lastUpdateDate,googleExpirationDate). - The full async validation issues array (this is what causes "Disapproved" in the MC UI; each issue carries
code,attribute,description,resolution,documentation). - Per-destination approval status (Shopping ads, Free listings, etc. —
approvedCountries/pendingCountries/disapprovedCountries).
found: false after a fresh insert is expected for the first 2–5 minutes — Merchant API's processed-product view is eventually consistent.
Response 200 — ItemGoogleStatus.
POST /items/:variantId/resync — Force-resync one variant
Required permission: googleMerchant: manage. Enqueues a fresh upsert intent. The drain re-evaluates eligibility, recomputes the hash, and either pushes a new ProductInput or no-ops if the hash matches.
Response 201
{ "data": { "variantId": "...", "enqueued": true } }POST /items/:variantId/delete-from-google — Remove from Google
Required permission: googleMerchant: manage. Enqueues a delete intent. The local catalog row is untouched; only the Google ProductInput is removed. Idempotent — a delete for a never-synced variant is a no-op.
Response 202
{ "data": { "variantId": "...", "enqueued": true } }POST /items/bulk/resync-failed — Re-enqueue all failed rows
Required permission: googleMerchant: manage. Re-enqueues every row currently in failed status; useful after fixing a root cause (e.g. broken shipping config in MC).
Response 202
{ "data": { "enqueued": <count> } }POST /items/bulk/resync-skipped — Re-enqueue all skipped rows
Required permission: googleMerchant: manage. Re-enqueues every row currently in skipped status; useful after re-publishing previously archived products (the eligibility flip will be re-evaluated at drain time).
Response 202 — same shape as resync-failed.
Architecture
Pipeline overview
catalog/inventory event ─┐
│ ┌─ products.insert ─► Google MC
▼ │
outbox row ──► drain ─┤
│ (BullMQ) └─ products.delete ─► Google MC
│
sync_state row
│
└─► counts surfaced via /status, /items, /errorsStorage (three tables, all in @sc/db)
| Table | Owner | Purpose |
|---|---|---|
integration_credentials | @sc/integration | OAuth tokens (provider google_merchant). One row. |
google_merchant_sync_state | this module | One row per variant ever pushed (or skipped); PK variant_id. Carries the hash of the last pushed payload for dedup. No FK to product_variant so a delete intent for a removed variant still has somewhere to look up the Google id. |
google_merchant_outbox | this module | Append-only intent log (upsert | delete). Drained FIFO. Composite index (processed_at, enqueued_at) keeps the drain query cheap. |
Listener subscriptions
Translates domain events into outbox rows. All handlers are async: true and swallow errors — a Google-Merchant write failure must never roll back the originating domain action.
| Event | Outbox write |
|---|---|
vendor.product.variant.created | upsert for the variant |
vendor.product.variant.updated | upsert for the variant |
vendor.product.variant.deleted | delete for the variant |
vendor.product.basics.updated | upsert for every non-deleted variant of the product (status/visibility may have flipped) |
vendor.product.media.updated | upsert for every non-deleted variant |
vendor.product.synced | upsert for every non-deleted variant |
vendor.product.deleted | delete for every variant of the product (including soft-deleted ones) |
inventory.stock.adjusted | upsert for the variant |
inventory.policy.updated | upsert for the variant |
A status flip from active to archived is detected by the mapper's eligibility check at drain time and converted into a delete — no explicit product.archived event needed.
Drain (BullMQ singleton repeating job)
- Registers at API boot with
repeat: { every: sync_interval_seconds * 1000 }and stablejobIdso multiple worker pods converge on one schedule. - Per tick:
- Bail if
sync_enabledis false. - Bail if feed config (merchant_id, data_source_id, country, language, currency, storefront_base_url) is incomplete.
- Read up to
batch_sizeoutbox rows whereprocessed_at IS NULL AND attempts < max_attempts, ordered FIFO. - Coalesce by variant — latest intent wins (delete supersedes upsert).
- Per coalesced entry, build a
DrainDecision:upsert(eligible variant, hash changed): map to ProductInput.delete(variant deleted, product archived, or operator-triggered): use storedgoogle_product_idor compose canonical name.noop(hash matches stored): mark outbox processed, leave sync_state.skip(ineligible, never synced): mark sync_stateskipped.drop(delete intent but never synced): no-op.
- Fan out concurrent
productInputs.insert/productInputs.deletecalls (Merchant API has nocustombatch); capped atGOOGLE_MERCHANT_MAX_IN_FLIGHT = 20. - Per result: mark sync_state
synced/failed/deleted; mark outbox processed or bump attempts.
- Bail if
If sync_interval_seconds changes after boot, restart the API to re-register the repeating job — BullMQ doesn't expose a hot-reschedule API.
Field mapping (variant → Merchant API ProductInput)
| Google field | Source | Notes |
|---|---|---|
offerId | variant.id | Stable per-variant id |
contentLanguage | setting | BCP 47 |
feedLabel | setting (country, uppercased) | Replaces Content API's targetCountry |
productAttributes.title | product.title | Trimmed, capped at 150 chars; variants share the parent title — disambiguation is via itemGroupId + dedicated attribute fields, not title text |
productAttributes.description | product.description (or subtitle, or title) | HTML-stripped, whitespace-collapsed, capped at 5000 |
productAttributes.link | storefront_base_url + storefront_product_path | {slug} token URL-encoded and substituted |
productAttributes.imageLink | variant.thumbnail → variant.images[0] → product.thumbnail → product.images[0] | First non-empty wins; bare S3 keys are prefixed with S3_PUBLIC_URL; absolute http(s) URLs pass through |
productAttributes.additionalImageLinks | remaining images | Deduplicated, capped at 10 |
productAttributes.availability | inventory_stock | in_stock if quantityOnHand − reservedQuantity > 0; backorder if zero + allowBackorder; in_stock if trackInventory=false; else out_of_stock |
productAttributes.price | variant.price | { amountMicros: subunits*10_000, currencyCode: setting } |
productAttributes.salePrice | variant.specialPrice (if < price and inside window) | Same conversion |
productAttributes.salePriceEffectiveDate | variant.specialPriceStart/End | { startTime, endTime } Schema$Interval |
productAttributes.condition | default_condition setting | new / refurbished / used |
productAttributes.brand | brand.title | Via product's brandId join; omitted if absent |
productAttributes.gtins | variant.ean → variant.upc → variant.barcode | Digits-only, validated as GTIN-8/12/13/14, emitted as single-element array |
productAttributes.mpn | variant.sku | Trimmed |
productAttributes.identifierExists | derived | false when (brand AND (gtin OR mpn)) is absent AND identifier_exists_fallback=true; omitted otherwise (Google defaults to true) |
productAttributes.itemGroupId | product.id | Groups all variants of one product |
productAttributes.googleProductCategory | default_google_product_category setting | Omitted if blank |
productAttributes.productTypes | category titles | Capped at 10 |
productAttributes.customLabel0 | vendor.name | For multi-vendor analytics in Google Ads |
productAttributes.customLabel1 | brand.title | Denormalized for filtering |
Eligibility (mapper rejects with EligibilityReason before mapping):
product_deleted—product.deletedAtset.variant_deleted—variant.deletedAtset.product_not_active—product.status !== "active".product_not_public—product.visibility !== "public".missing_price—variant.pricenull or<= 0.missing_storefront_slug—product.slugempty.
Hash dedup
The mapper computes a SHA-256 hash of the canonical JSON (sorted keys) of the ProductInput. The drain compares this against sync_state.last_pushed_hash and skips the Content API call when they match. A no-op repush is cheap: one SELECT + one outbox UPDATE.
Retries
Per-row failures bump outbox.attempts. Rows with attempts >= max_attempts are filtered out of the next drain (attempts < max_attempts is the drain's read predicate); they remain in the outbox visible via GET /errors so the operator can investigate. POST /items/:variantId/resync writes a fresh outbox row (the coalescer collapses it with any pending row for the same variant).
Whole-batch failures (auth errors, quota exhaustion) bump attempts for every row in the batch and surface in logs; the next drain tick retries.
Token refresh
IntegrationCredentialsService.getFreshAccessToken("google_merchant") is single-flight per process (per the integration module's in-memory mutex), and re-reads the credential row inside the lock to honour a token refresh that landed via another path. If Google rotates the refresh token on use, the rotated value is persisted and we keep going.
Settings
All under admin scope, group google_merchant. Read at call-time so edits via PATCH /admin/settings/admin apply on the next drain tick / OAuth start.
| Key | Type | Default | Notes |
|---|---|---|---|
client_id | string | "" | Google Cloud OAuth 2.0 Client ID |
client_secret | string | "" | SENSITIVE. Required for code→token exchange + refresh |
redirect_uri | URL | "" | Falls back to ${PUBLIC_API_URL}/admin/google-merchant/oauth/callback |
merchant_id | string | "" | Numeric Merchant Center account id |
data_source_id | string | "" | Numeric primary API data source id |
country | ISO 3166-1 alpha-2 | "IN" | Drives feedLabel and stamped as country target |
language | BCP 47 | "en" | Stamped as contentLanguage |
currency | ISO 4217 | "INR" | Stamped on every Price.currencyCode |
storefront_base_url | URL | "" | Public storefront origin; required for link |
storefront_product_path | path template | "/product/{slug}" | {slug} is interpolated with URL-encoded product.slug |
default_google_product_category | string | "" | Numeric taxonomy id or full path; omitted if blank |
default_condition | enum | "new" | new / refurbished / used |
identifier_exists_fallback | boolean | false | When true, emits identifierExists=false for rows missing brand + (gtin OR mpn) |
sync_enabled | boolean | false | Master switch; outbox still accepts writes when off, drain skips |
sync_interval_seconds | int 10..3600 | 60 | Restart API to apply changes |
batch_size | int 1..1000 | 500 | Outbox rows per drain tick |
max_attempts | int 1..20 | 5 | Per-outbox-row retry cap |
Domain events
The plugin consumes events from @sc/catalog and @sc/inventory (see Listener subscriptions above). It does not emit any events of its own — sync state is consulted by ops, not by other modules.
If you need to react to "this variant just got pushed to Google" downstream (e.g. update a denormalized field on the product card), poll GET /items?status=synced or subscribe to the listener pattern via a separate plugin; we deliberately don't add a domain event surface here.
Operational notes
- Operator-side first-time setup lives in
docs/google-merchant/setup.md— Google Cloud project, OAuth client, Merchant Center prerequisites, settings configuration, OAuth handshake. This document covers the post-setup HTTP surface only. - Quota. Google's default quota is ~100k Merchant API requests/day per Merchant Center. With
batch_size=500andsync_interval_seconds=60, the steady-state ceiling is well within quota even with high churn. Bootstrap on a 100k-variant catalog is ~100k inserts, at the edge of the daily quota — schedule first-time bootstraps off-hours or contact Google to raise the quota first. - Process model. The OAuth refresh coalescing assumes the Nest API + BullMQ worker share one Node process. If the deployment is split into separate processes, swap
IntegrationCredentialsService's in-memory mutex for a Redis or Postgres advisory lock (commented in the integration service). - Settings hot-reload. Most settings are read at call time. The exception is
sync_interval_seconds— read once at boot to register the BullMQ repeating job. Restart to pick up changes.
Related modules
@sc/integration— owns theintegration_credentialstable andIntegrationCredentialsService. This plugin registers a token refresher against it during bootstrap.@sc/catalog— owns the product/variant tables; this plugin reads from them and listens to its events.@sc/inventory— ownsinventory_stock; this plugin reads availability + listens to stock/policy events.@sc/settings— backs thegoogle_merchantadmin group.@sc/queue— provides the BullMQ infra the drain processor consumes.@sc/cache— backs the OAuth state nonce store (Redis with 10-minute TTL).@sc/admin-rbac— gates every endpoint viagoogleMerchant:view/googleMerchant:manage. See admin-rbac.md.
Global Scripts Module — Admin
HTTP surface for managing global scripts — raw HTML/JS snippets (analytics tags, marketing pixels, inline <style> blocks, chat widgets) that the storefront injects into one of…
Klaviyo Marketing Module — Admin
HTTP surface for the Klaviyo marketing-automation integration — connect/disconnect the Klaviyo account, inspect each outbound sync surface (profiles, events, product catalog,…