Channel sync architecture
Every external sales channel — Amazon, eBay, Meta Commerce (Facebook + Instagram Shop), TikTok Shop, Google Merchant Center — uses the same pattern:
- Merchant clicks “Sync” or saves a product.
- Gateway enqueues a BullMQ job onto the channel’s dedicated queue.
- 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.credentialsfor 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.tsto 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.channelProductIdsso 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
- Add the queue name to
packages/shared/src/jobs/queue-names.tswith a doc-comment. - Write the worker at
apps/worker/src/jobs/<channel>-sync.ts. Pattern:- Export
handle<Channel>SyncJob(job: Job<JobData>): Promise<Result>. - Use
withSystemContextfor cross-tenant DB reads (orwithJobTenantContextif scoped). - Implement OAuth refresh on 401.
- Honour 429
Retry-After.
- Export
- Register the worker in
apps/worker/src/worker.tswith appropriate concurrency + lock duration. Add to the graceful-shutdown list. - Add the channel to
SYNC_PROVIDERS+CHANNEL_QUEUEinenqueue.tsandchannelSyncService.ts. - Add a
/channels/<channel>dashboard page + the entry to/channels/index.vue(withmanagePath) + the sidebar submenu indefault.vue. - Add the route handler to
apps/gateway/src/routes/<channel>.tsand mount it inindex.ts. - If the provider key differs from the canonical channel id, add an entry to
PROVIDER_ALIASES. - Add a doc page mirroring amazon-sp-api.md.
Related
- Amazon SP-API
- eBay channel
- Meta Commerce
- Channel sync placeholder fix — the 2026 audit closure that wired the placeholders to real workers.