Channel sync architecture

Every external sales channel — Amazon, eBay, Meta Commerce (Facebook + Instagram Shop), TikTok Shop, Google Merchant Center — uses the same pattern:

  1. Merchant clicks “Sync” or saves a product.
  2. Gateway enqueues a BullMQ job onto the channel’s dedicated queue.
  3. Worker handles auth, retries, rate limits, OAuth refresh, and persists per-product status.

The gateway never calls a channel API directly. This gives every channel the same operational shape (queue depth, DLQ, exponential backoff) and isolates merchant-facing latency from third-party slowness.

The enqueue helper

apps/gateway/src/services/channelSync/enqueue.ts:

export const SYNC_PROVIDERS = [
  'amazon_sp_api',
  'google_merchant',
  'tiktok_shop',
  'meta_commerce',
] as const;
export type SyncProvider = (typeof SYNC_PROVIDERS)[number];

export async function enqueueChannelSync(args: {
  provider: SyncProvider;
  storeId: string;
  productId: string;
  operation: 'upsert' | 'delete';
}): Promise<void> {
  /* … */
}

Each provider maps to a dedicated queue. The job carries { storeId, productId, operation }; everything else (credentials, channel-specific product mapping) the worker fetches itself.

Dedup

Every enqueue carries a stable jobId of the shape <provider>:<productId>:<operation>:<10-second bucket>. Two save clicks within 10 seconds collapse into one job. A merchant mashing “Sync now” doesn’t multiply the rate-limit burn.

The channel sync service

apps/gateway/src/services/products/channelSyncService.ts is the per-product fan-out entrypoint called by the product write path. It accepts a product + an array of { channel, enabled, credentials } configs and enqueues one job per enabled channel.

const CHANNEL_QUEUE: Partial<Record<Channel, QueueName>> = {
  google_shopping: QUEUE_NAMES.gmcProductSync,
  instagram: QUEUE_NAMES.metaCommerceSync,
  tiktok_shop: QUEUE_NAMES.tiktokProductSync,
};

const DEFERRED_CHANNELS: Channel[] = ['pinterest'];

Pre-2026, this file logged placeholder for instagram, tiktok_shop, and pinterest — the workers shipped earlier but were never wired here. The 2026 audit closure replaced the placeholders with real enqueues. pinterest is explicitly marked channel not yet implemented rather than faking success — honest signal beats silent no-op.

storefront is a no-op (the storefront reads the database directly).

Per-channel quick reference

Channel Provider key (integrations.provider) Worker Queue Endpoint
Amazon amazon_sp_api (legacy: amazon) amazon-listing-sync.ts + amazon-order-import.ts amazonListingSync, amazonOrderImport SP-API v2021-08-01 / Orders v0
eBay ebay ebay-listing-sync.ts ebayListingSync Sell Inventory API
Meta Commerce meta_commerce meta-commerce-sync.ts metaCommerceSync Graph API Catalog Batch (/v22.0/{catalog_id}/items_batch)
TikTok Shop tiktok_shop tiktok-product-sync.ts tiktokProductSync Open Platform v202309 Product API (HMAC-signed)
Google Merchant Center google_shopping gmc-product-sync.ts gmcProductSync Content API v2.1

Each worker:

  • Reads integrations.credentials for its provider key.
  • Refreshes the OAuth/access token on 401 (per-provider strategy).
  • Honours per-API rate limits via 429 Retry-After + exponential backoff.
  • Concurrency-capped in worker.ts to keep a single replica under the seller-key budget; horizontal replicas share the queue so global throughput scales with deployment size.
  • Persists the channel’s product id + last-known approval state on integrations.credentials.channelProductIds so the dashboard can render per-product badges.

Dashboard surfaces

Each channel has a /channels/<name> page mirroring the same shape:

  • KPI strip (status, marketplace/catalog/store, last sync, active products).
  • Connect form (provider-specific credentials).
  • Manage panel (sync all, sync one, delist, disconnect).
  • Status detail (rate-limit headroom, last error, per-product approval count).

Channel grid at /channels lists every supported channel with a connection state pill + a “Manage” / “Set up” CTA wired to managePath.

Sidebar wiring: Sales channels → submenu of Amazon, eBay, Facebook & Instagram. The “All channels” parent link goes to /channels.

Status alias map

The channels page does a single GET /api/v1/connect to list all integrations, then maps each channel card to its row. Because integration rows may carry one of several legacy provider keys (Amazon: amazon or amazon_sp_api; TikTok: tiktok or tiktok_shop; GMC: google_merchant or google_shopping), the page consults a PROVIDER_ALIASES map:

const PROVIDER_ALIASES: Record<string, string[]> = {
  amazon: ['amazon_sp_api', 'amazon'],
  meta_commerce: ['meta_commerce'],
  tiktok: ['tiktok_shop', 'tiktok'],
  google_merchant: ['google_merchant', 'google_shopping'],
  ebay: ['ebay'],
};

So a merchant whose row was written before key standardisation can still see the right connected pill.

Adding a new channel

  1. Add the queue name to packages/shared/src/jobs/queue-names.ts with a doc-comment.
  2. Write the worker at apps/worker/src/jobs/<channel>-sync.ts. Pattern:
    • Export handle<Channel>SyncJob(job: Job<JobData>): Promise<Result>.
    • Use withSystemContext for cross-tenant DB reads (or withJobTenantContext if scoped).
    • Implement OAuth refresh on 401.
    • Honour 429 Retry-After.
  3. Register the worker in apps/worker/src/worker.ts with appropriate concurrency + lock duration. Add to the graceful-shutdown list.
  4. Add the channel to SYNC_PROVIDERS + CHANNEL_QUEUE in enqueue.ts and channelSyncService.ts.
  5. Add a /channels/<channel> dashboard page + the entry to /channels/index.vue (with managePath) + the sidebar submenu in default.vue.
  6. Add the route handler to apps/gateway/src/routes/<channel>.ts and mount it in index.ts.
  7. If the provider key differs from the canonical channel id, add an entry to PROVIDER_ALIASES.
  8. Add a doc page mirroring amazon-sp-api.md.

Related