Supercommerce API Docs
Admin API

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 via CatalogGoogleMerchantModule.forRoot() in apps/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 groupPermission
GET /status, GET /errors, GET /items, GET /items/:variantId, GET /items/:variantId/google-status, GET /data-sourcesgoogleMerchant: 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/callbacknone — CSRF via state nonce

Response envelope

{
  "data": <payload>,
  "message": "Success",
  "statusCode": 200,
  "metadata": { /* optional, pagination */ }
}

Error envelope

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR, google_merchant_misconfigured, google_merchant_oauth_state_invalid, google_merchant_oauth_exchange_failed
401UNAUTHORIZED
403FORBIDDEN
404NOT_FOUND (variant not in catalog)
500INTERNAL_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

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

StatusCodeWhen
400google_merchant_misconfiguredclient_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

NameNotes
codeRequired on success; OAuth authorization code
stateRequired on success; nonce from /oauth/start
error, error_descriptionSet 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

StatusCodeWhen
400google_merchant_oauth_state_invalidState missing / expired / replayed
400google_merchant_oauth_exchange_failedGoogle refused the code; description includes Google's reason
400provider errorWhen ?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 200ConnectionStatus.

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

NameTypeDefault
pageint >= 11
limitint 1..20050

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

NameTypeNotes
pageint >= 1Default 1
limitint 1..100Default 50
statusSyncStatus?synced / pending / failed / skipped / deleted / never_synced
searchstring?Case-insensitive LIKE on variant.id / variant.sku / product.title / product.slug. LIKE meta-characters are escaped.
eligibleOnlyboolean?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 200ItemDetail.

Errors

StatusCodeWhen
404NOT_FOUNDVariant 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 200ItemGoogleStatus.

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, /errors

Storage (three tables, all in @sc/db)

TableOwnerPurpose
integration_credentials@sc/integrationOAuth tokens (provider google_merchant). One row.
google_merchant_sync_statethis moduleOne 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_outboxthis moduleAppend-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.

EventOutbox write
vendor.product.variant.createdupsert for the variant
vendor.product.variant.updatedupsert for the variant
vendor.product.variant.deleteddelete for the variant
vendor.product.basics.updatedupsert for every non-deleted variant of the product (status/visibility may have flipped)
vendor.product.media.updatedupsert for every non-deleted variant
vendor.product.syncedupsert for every non-deleted variant
vendor.product.deleteddelete for every variant of the product (including soft-deleted ones)
inventory.stock.adjustedupsert for the variant
inventory.policy.updatedupsert 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 stable jobId so multiple worker pods converge on one schedule.
  • Per tick:
    1. Bail if sync_enabled is false.
    2. Bail if feed config (merchant_id, data_source_id, country, language, currency, storefront_base_url) is incomplete.
    3. Read up to batch_size outbox rows where processed_at IS NULL AND attempts < max_attempts, ordered FIFO.
    4. Coalesce by variant — latest intent wins (delete supersedes upsert).
    5. Per coalesced entry, build a DrainDecision:
      • upsert (eligible variant, hash changed): map to ProductInput.
      • delete (variant deleted, product archived, or operator-triggered): use stored google_product_id or compose canonical name.
      • noop (hash matches stored): mark outbox processed, leave sync_state.
      • skip (ineligible, never synced): mark sync_state skipped.
      • drop (delete intent but never synced): no-op.
    6. Fan out concurrent productInputs.insert / productInputs.delete calls (Merchant API has no custombatch); capped at GOOGLE_MERCHANT_MAX_IN_FLIGHT = 20.
    7. Per result: mark sync_state synced / failed / deleted; mark outbox processed or bump attempts.

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 fieldSourceNotes
offerIdvariant.idStable per-variant id
contentLanguagesettingBCP 47
feedLabelsetting (country, uppercased)Replaces Content API's targetCountry
productAttributes.titleproduct.titleTrimmed, capped at 150 chars; variants share the parent title — disambiguation is via itemGroupId + dedicated attribute fields, not title text
productAttributes.descriptionproduct.description (or subtitle, or title)HTML-stripped, whitespace-collapsed, capped at 5000
productAttributes.linkstorefront_base_url + storefront_product_path{slug} token URL-encoded and substituted
productAttributes.imageLinkvariant.thumbnailvariant.images[0]product.thumbnailproduct.images[0]First non-empty wins; bare S3 keys are prefixed with S3_PUBLIC_URL; absolute http(s) URLs pass through
productAttributes.additionalImageLinksremaining imagesDeduplicated, capped at 10
productAttributes.availabilityinventory_stockin_stock if quantityOnHand − reservedQuantity > 0; backorder if zero + allowBackorder; in_stock if trackInventory=false; else out_of_stock
productAttributes.pricevariant.price{ amountMicros: subunits*10_000, currencyCode: setting }
productAttributes.salePricevariant.specialPrice (if < price and inside window)Same conversion
productAttributes.salePriceEffectiveDatevariant.specialPriceStart/End{ startTime, endTime } Schema$Interval
productAttributes.conditiondefault_condition settingnew / refurbished / used
productAttributes.brandbrand.titleVia product's brandId join; omitted if absent
productAttributes.gtinsvariant.eanvariant.upcvariant.barcodeDigits-only, validated as GTIN-8/12/13/14, emitted as single-element array
productAttributes.mpnvariant.skuTrimmed
productAttributes.identifierExistsderivedfalse when (brand AND (gtin OR mpn)) is absent AND identifier_exists_fallback=true; omitted otherwise (Google defaults to true)
productAttributes.itemGroupIdproduct.idGroups all variants of one product
productAttributes.googleProductCategorydefault_google_product_category settingOmitted if blank
productAttributes.productTypescategory titlesCapped at 10
productAttributes.customLabel0vendor.nameFor multi-vendor analytics in Google Ads
productAttributes.customLabel1brand.titleDenormalized for filtering

Eligibility (mapper rejects with EligibilityReason before mapping):

  • product_deletedproduct.deletedAt set.
  • variant_deletedvariant.deletedAt set.
  • product_not_activeproduct.status !== "active".
  • product_not_publicproduct.visibility !== "public".
  • missing_pricevariant.price null or <= 0.
  • missing_storefront_slugproduct.slug empty.

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.

KeyTypeDefaultNotes
client_idstring""Google Cloud OAuth 2.0 Client ID
client_secretstring""SENSITIVE. Required for code→token exchange + refresh
redirect_uriURL""Falls back to ${PUBLIC_API_URL}/admin/google-merchant/oauth/callback
merchant_idstring""Numeric Merchant Center account id
data_source_idstring""Numeric primary API data source id
countryISO 3166-1 alpha-2"IN"Drives feedLabel and stamped as country target
languageBCP 47"en"Stamped as contentLanguage
currencyISO 4217"INR"Stamped on every Price.currencyCode
storefront_base_urlURL""Public storefront origin; required for link
storefront_product_pathpath template"/product/{slug}"{slug} is interpolated with URL-encoded product.slug
default_google_product_categorystring""Numeric taxonomy id or full path; omitted if blank
default_conditionenum"new"new / refurbished / used
identifier_exists_fallbackbooleanfalseWhen true, emits identifierExists=false for rows missing brand + (gtin OR mpn)
sync_enabledbooleanfalseMaster switch; outbox still accepts writes when off, drain skips
sync_interval_secondsint 10..360060Restart API to apply changes
batch_sizeint 1..1000500Outbox rows per drain tick
max_attemptsint 1..205Per-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=500 and sync_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.

  • @sc/integration — owns the integration_credentials table and IntegrationCredentialsService. 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 — owns inventory_stock; this plugin reads availability + listens to stock/policy events.
  • @sc/settings — backs the google_merchant admin 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 via googleMerchant:view / googleMerchant:manage. See admin-rbac.md.

On this page