Meta Catalog Module — Admin
HTTP surface for syncing eligible product variants to a Meta Commerce Catalog (Facebook + Instagram Shops) via the Catalog Batch API (POST /{catalog_id}/items_batch) and the…
HTTP surface for syncing eligible product variants to a Meta Commerce Catalog (Facebook + Instagram Shops) via the Catalog Batch API (POST /{catalog_id}/items_batch) and the asynchronous Check Batch Request Status poller. Covers OAuth lifecycle, business + catalog selection (with System User token mint), configurable feed settings, bootstrap, per-row item visibility + management, and bulk re-enqueue.
Source:
api-modules/catalog-meta/src/controllers/admin-meta-*.controller.ts. Registered viaCatalogMetaModule.forRoot()inapps/api/src/app.module.ts. Removing that line disables the plugin entirely — listeners stop firing, the BullMQ drain + poll processors never register, and these admin endpoints disappear from the OpenAPI spec.For one-time credential acquisition (Meta App + Business Manager + Catalog prerequisites), see the Meta setup guide.
Conventions
Authentication
All endpoints require a Better-Auth admin session and a role granting the matching metaCatalog:* permission. The OAuth callback (GET /admin/meta/oauth/callback) is the single exception — it's the redirect target Facebook 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/meta-status, GET /businesses | metaCatalog: view |
GET /oauth/start, DELETE /oauth, POST /select-catalog, POST /bootstrap, POST /resync/:variantId, POST /items/:variantId/resync, POST /items/:variantId/delete-from-meta, POST /items/bulk/* | metaCatalog: 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, meta_misconfigured, meta_oauth_state_invalid, meta_oauth_exchange_failed, meta_account_error, meta_catalog_unassignable |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND (variant not in catalog) |
| 500 | INTERNAL_SERVER_ERROR (Graph API call failed, rate limit, etc.); the Graph wrapper preserves Meta's fbType / fbCode / fbSubcode / fbTraceId on meta_graph_error instances |
Currency
Prices are stored as integer subunits (paise / cents). The mapper formats them as "XX.XX CCY" per Meta's product data spec (e.g. "299.00 INR") — two decimals, single space, ISO 4217 code from the configured currency setting. There is no FX conversion; the storefront pricing currency must match meta.currency.
Async submission model
Meta's Catalog Batch API is asynchronous in two places:
POST /{catalog_id}/items_batch— accepts up to 5000 row updates per call but returns immediately with one or more opaque handles. The HTTP 200 means "queued," not "validated."GET /{catalog_id}/check_batch_request_status?handle=…— polled untilstatus="finished", then surfaces per-row validation errors.
The plugin runs two BullMQ singleton repeaters: the drain submits items_batch calls, the poller resolves handles. A row's sync_state.status therefore has an extra hop vs the Google equivalent — pending → submitted → synced (or → failed) — and there's a dedicated handles_per_poll_tick knob plus a handle_poll_max_age_minutes quarantine timer for handles Meta never resolves.
allow_upsert
Every items_batch submission sets allow_upsert: true, which collapses CREATE / UPDATE: the same data.id (= our retailer_id = our variant.id) either updates the existing item or creates it if missing. The drain therefore only deals in upsert and delete intents — there is no "create" branch.
Permissions / App Review note
catalog_management + business_management are Advanced Access permissions. In Meta App Development mode they work for App developers/admins/testers against their own Business Manager + Catalog with no App Review required. Going live against other people's catalogs requires App Review + Business Verification — see Going live in the Meta setup guide.
Domain types
MetaConnectionStatus
type MetaTokenKind = "user" | "system_user";
type MetaConnectionStatus = {
connected: boolean;
accountId: string | null; // FB user id or System User id
connectedAt: string | null; // ISO
scope: string | null; // OAuth scope granted
syncEnabled: boolean;
configuration: {
oauthClient: "configured" | "missing"; // app_id + app_secret present
feed: "configured" | "missing"; // catalog_id + currency + storefront URL present
missingKeys: string[]; // setting keys still blank
};
selection: { // null before select-catalog
businessId: string;
businessName: string;
catalogId: string;
catalogName: string;
systemUserId: string;
tokenKind: MetaTokenKind;
} | null;
counts: {
synced: number;
submitted: number; // sent to items_batch, awaiting validation
pending: number; // sync_state row but never pushed
failed: number;
skipped: number;
deleted: number;
outboxPending: number; // unprocessed outbox rows
handlesPending: number; // pending meta_batch_handles
};
};MetaBusinessSummary / MetaCatalogSummary
type MetaCatalogSummary = {
id: string;
name: string;
productCount: number | null;
vertical: string | null;
};
type MetaBusinessSummary = {
id: string;
name: string;
ownedCatalogs: MetaCatalogSummary[]; // .limit(50) on the Graph side
clientCatalogs: MetaCatalogSummary[]; // catalogs assigned to this business
};MetaItemSummary / MetaItemDetail
type SyncStatus =
| "synced"
| "submitted" // items_batch accepted, awaiting check_batch_request_status
| "pending"
| "failed"
| "skipped"
| "deleted"
| "never_synced"; // synthesized — no sync_state row yet
type MetaItemSummary = {
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;
metaRetailerId: string | null; // = variant.id once submitted at least once
lastHandle: string | null; // most recent items_batch handle this row belonged to
lastPushedAt: string | null;
lastError: string | null;
attempts: number;
};
type MetaItemDetail = {
variant: { id, sku, price, specialPrice, thumbnail };
product: { id, title, slug, status, visibility, thumbnail };
inventory: { trackInventory, quantityOnHand, reservedQuantity, allowBackorder } | null;
syncState: { status, metaRetailerId, lastHandle, lastPushedHash, lastPushedAt, lastError, attempts } | null;
eligibility: { eligible: boolean; reason: string | null };
mappedItemData: Record<string, unknown> | null; // live mapper preview (items_batch `data` payload)
};MetaItemLiveStatus
The shape returned by GET /items/:variantId/meta-status — a direct probe of GET /{catalog_id}/products?filter=…:
type MetaItemLiveStatus = {
found: boolean;
metaId: string | null;
retailerId: string | null;
availability: string | null; // "in stock" / "out of stock" / "preorder" / …
price: string | null; // "299.00 INR"
reviewStatus: string | null; // Meta's review verdict
errors: Array<{ message: string }>;
};If feed settings are incomplete, the response is found: false with errors: [{ message: "<config error>" }] rather than a 500, so the admin UI can render a clear message.
Endpoints
Base path: /admin/meta.
OAuth lifecycle
GET /oauth/start — Build the consent URL
Required permission: metaCatalog: manage.
Generates a fresh CSRF state nonce, stashes it in Redis with a 10-minute TTL, and returns a Facebook Login dialog URL with scope=catalog_management,business_management&response_type=code.
Response 200
{ "data": { "authUrl": "https://www.facebook.com/v25.0/dialog/oauth?..." } }Errors
| Status | Code | When |
|---|---|---|
| 400 | meta_misconfigured | app_id or app_secret blank |
GET /oauth/callback — Facebook redirect target
No auth guard — Facebook 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, error_reason | Set when the operator denied consent |
On success: exchanges the code for a short-lived user token, immediately upgrades it to a 60-day long-lived user token via fb_exchange_token, fetches /me for the audit trail, and persists in integration_credentials (provider meta_catalog, tokenKind: "user"). Then either redirects to ${PUBLIC_ADMIN_URL}/settings/meta?connected=1 or returns { connected: true }.
The token is swapped over to a non-expiring System User token once the operator picks a catalog via POST /select-catalog (see below).
Errors
| Status | Code | When |
|---|---|---|
| 400 | meta_oauth_state_invalid | State missing / expired / replayed |
| 400 | meta_oauth_exchange_failed | Facebook refused the code; description includes the Graph error |
| 400 | provider error | When ?error=… was set by Facebook (e.g. user denied) |
DELETE /oauth — Disconnect
Required permission: metaCatalog: manage. Deletes the integration_credentials row. The sync worker will skip every subsequent drain with Integration not connected. Existing rows in meta_sync_state and meta_batch_handles are kept (the operator may reconnect with the same business + catalog; we don't want to lose the dedup hashes or in-flight handle records).
Response 200
{ "data": { "disconnected": true } }Business + catalog selection
These two endpoints replace google-merchant's register-developer / data-sources pair.
GET /businesses — List businesses + their catalogs
Required permission: metaCatalog: view.
Calls /me/businesses?fields=id,name,owned_product_catalogs.limit(50){…},client_product_catalogs.limit(50){…} against the stored token. Returns each business the connected admin manages, with up to 50 catalogs per slot. Empty array means the connected FB account is not a Business admin or the App's role list doesn't include them.
Response 200 — array of MetaBusinessSummary.
POST /select-catalog — Persist selection + mint System User token
Required permission: metaCatalog: manage.
Body
{
"businessId": "1234567890", // 1..100 chars
"businessName": "Acme Holdings", // 1..200 chars, cached for admin UI
"catalogId": "9876543210",
"catalogName": "Acme Master Catalog",
"purgeOldCatalog": true // default true; only read on a CHANGE of catalog
}Atomic-ish sequence (the controller does not wrap these in a single transaction — each step is idempotent on retry):
- Ensure System User. Look up
system_usersin the chosen business by canonical name"Supercommerce Catalog Sync". If absent, create one with roleADMIN. If present, reuse its id. - Assign catalog.
POST /{catalog_id}/assigned_userswithuser={system_user_id}&tasks=["MANAGE"]. Idempotent on Meta's side. (The assignment lives on the catalog edge —POST /{system_user_id}/assigned_product_catalogsdoes not exist and returns "Unsupported post request".) - Mint System User access token.
POST /{system_user_id}/access_tokenswithbusiness_app=app_id&scope=catalog_management,business_management. Theset_token_expires_in_60_daysparameter is intentionally omitted to mint a non-expiring token. - Swap credential. Overwrite the
integration_credentialsrow with the new token; clearaccessTokenExpiresAt(null = "no expiry recorded" → the refresher will never fire); setmetadata.tokenKind = "system_user". - Persist settings.
meta.business_id,meta.business_name,meta.catalog_id,meta.catalog_name,meta.system_user_id,meta.token_kind = "system_user".
Changing the catalog
Selecting a catalog different from the one already stored is a migration, not a settings write. Nothing we know locally applies to the new catalog, and the old one keeps serving whatever we last pushed — so before the flip:
- Token check. For a
system_usercredential, the new catalog is assigned to the System User first. This runs before anything destructive: if Meta refuses (a System User token cannot manage business assets), the request fails withmeta_catalog_unassignableand nothing has changed. Reconnect via OAuth with an admin account and retry. - Adopt unbound rows.
meta_sync_staterows written before thecatalog_idcolumn existed are claimed for the outgoing catalog so the steps below can see them. - Retire in-flight handles. Pending
meta_batch_handlesfor the outgoing catalog are markedfailedwithcatalog_switched. - Purge (when
purgeOldCatalog, the default). A DELETE items_batch is submitted for every retailer id we believe the outgoing catalog still holds. This is the last moment it can happen — after the flip we no longer hold the bindings, and the only remaining cleanup is by hand in Commerce Manager. Skipped underfeed_mode: "preview", which never writes to Meta. - Clear local state.
meta_sync_staterows for the outgoing catalog are deleted and unprocessedmeta_outboxrows are retired asdiscarded_catalog_switched. - Flip the settings, then enqueue a full bootstrap into the new catalog.
Response 200
{
"data": {
"systemUserId": "111222333444555",
"catalogId": "9876543210",
"tokenKind": "system_user",
"catalogChanged": true,
"previousCatalogId": "1122334455", // null on a first selection
"purgedItems": 1840, // rows DELETEd from the outgoing catalog
"clearedVariants": 1840, // sync_state rows dropped
"retiredHandles": 2, // in-flight batches abandoned
"enqueuedVariants": 1902 // queued for the new catalog
}
}The five counters are all 0 and catalogChanged is false when the selected catalog is the one already stored.
Errors
| Status | Code | When |
|---|---|---|
| 400 | meta_account_error | Meta refused System User creation or token mint |
| 400 | meta_catalog_unassignable | Switching catalogs on a System User token Meta will not grant MANAGE on the new catalog — reconnect via OAuth as an admin. Raised before any state changes. |
| 500 | upstream Graph error | fbCode / fbSubcode surfaced on the response body |
Lifecycle + visibility
GET /status — Connection + config + counts
Required permission: metaCatalog: view. Single source of truth for the admin UI's "Meta Catalog" page — combines connection state, configuration completeness, the persisted selection, and the sync_state + handle aggregate counts.
Response 200 — MetaConnectionStatus.
POST /bootstrap — Push every eligible variant
Required permission: metaCatalog: 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 Meta's stored copy are no-ops; only changed rows actually hit the Catalog Batch API.
Response 202
{ "data": { "enqueuedVariants": 444 } }POST /resync/:variantId — Force-resync one variant (main controller)
Required permission: metaCatalog: manage. Identical semantics to POST /items/:variantId/resync (below) — exposed at both paths so the dashboard can hit either depending on the surface it's on.
Response 200
{ "data": { "variantId": "...", "enqueued": true } }GET /errors — Paginated failed rows
Required permission: metaCatalog: view.
Query
| Name | Type | Default |
|---|---|---|
page | int >= 1 | 1 |
limit | int 1..200 | 50 |
Response 200 — paginated envelope of failed rows:
type MetaFailedItem = {
variantId: string;
metaRetailerId: string | null;
lastHandle: string | null;
attempts: number;
lastError: string | null;
lastPushedAt: string | null;
updatedAt: string;
};Items management
GET /items — Paginated catalog × sync_state list
Required permission: metaCatalog: view.
LEFT-joins product_variant + product with meta_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 / submitted / 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 MetaItemSummary[].
GET /items/:variantId — Full detail + mapper preview
Required permission: metaCatalog: view.
Returns the variant + product + inventory snapshot, the persisted sync_state, an eligibility verdict, and a live preview of the items_batch data payload the mapper would push right now (mappedItemData). The preview is invaluable for debugging — it's the exact payload the next drain would send.
Response 200 — MetaItemDetail.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Variant not in catalog |
GET /items/:variantId/meta-status — Live Graph API probe
Required permission: metaCatalog: view.
Calls GET /{catalog_id}/products?filter=…retailer_id…&fields=id,retailer_id,availability,price,review_status,errors&limit=1 and returns the first match, if any. Surfaces:
- Meta's view of
availability(formatted strings, e.g."in stock"). - The current
priceMeta has stored ("299.00 INR"). - The async
review_status(drives "Available" / "Pending Review" / "Rejected" in Commerce Manager). - The array of validation
errorsMeta is currently flagging on the row.
When feed config is incomplete (MetaMisconfiguredError), the response is a clean found: false with the misconfig reason inside errors — no 500.
Response 200 — MetaItemLiveStatus.
POST /items/:variantId/resync — Force-resync one variant
Required permission: metaCatalog: manage. Enqueues a fresh upsert intent. The drain re-evaluates eligibility, recomputes the hash, and either pushes a new row or no-ops if the hash matches.
Response 200
{ "data": { "variantId": "...", "enqueued": true } }POST /items/:variantId/delete-from-meta — Remove from Meta
Required permission: metaCatalog: manage. Enqueues a delete intent. The local catalog row is untouched; only the Meta-side item 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: metaCatalog: manage. Re-enqueues every row currently in failed status; useful after fixing a root cause (e.g. fixed brand / GTIN data, fixed image URLs).
Response 202
{ "data": { "enqueued": <count> } }POST /items/bulk/resync-skipped — Re-enqueue all skipped rows
Required permission: metaCatalog: 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 ─┐
│
▼
outbox row ──► drain ──► items_batch (UPDATE/DELETE, allow_upsert=true)
│ (BullMQ) │
│ ▼
│ meta_batch_handles row
│ │
│ ▼
│ poll ──► check_batch_request_status
│ │ (BullMQ, separate processor)
▼ ▼
sync_state row ◄────────────┘ (synced / failed / deleted, partitioned by intent)
│
└─► counts surfaced via /status, /items, /errorsStorage (four tables, all in @sc/db)
| Table | Owner | Purpose |
|---|---|---|
integration_credentials | @sc/integration | OAuth tokens (provider meta_catalog). One row; metadata.tokenKind flips user → system_user after /select-catalog. |
meta_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, the catalog_id that hash and status describe, plus the in-flight last_handle so the poller can correlate per-item errors back to a variant. No FK to product_variant. |
meta_outbox | this module | Append-only intent log (upsert | delete). Drained FIFO. Composite index (processed_at, enqueued_at) keeps the drain query cheap. |
meta_batch_handles | this module | One row per opaque handle returned by items_batch; delete_retailer_ids jsonb records which retailer_ids in the handle were DELETE intents so a clean poll resolves them to sync_state.status="deleted" (not synced). |
Listener subscriptions
Translates domain events into outbox rows. All handlers are async: true and swallow errors — a Meta 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 (
catalog_id,currency,storefront_base_url) is incomplete. - Read up to
min(batch_size, META_ITEMS_BATCH_MAX=5000)outbox rows whereprocessed_at IS NULL AND attempts < max_attempts, FIFO. - Coalesce by variant — latest intent wins (delete supersedes upsert).
- Per coalesced entry, build a
DrainDecision:upsert(eligible variant, hash changed): map to items_batchdata(method=UPDATE).delete(variant deleted, product archived, or operator-triggered): method=DELETE withdata.id = retailer_id.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.
- If
feed_mode = "preview": log + mark outbox processed without HTTP submit.sync_stateis not touched (so flipping preview off restores prior synced state intact). - Otherwise: one
POST /{catalog_id}/items_batchper tick (capped atMETA_ITEMS_BATCH_MAX=5000rows). Cadence is governed by the repeating BullMQ schedule, not by fanning out concurrent calls — that's what burns BUC rate-limit budget. - Per returned handle: persist a
meta_batch_handlesrow includingdelete_retailer_ids(the subset of the chunk whose intent was DELETE). Mark every retailer_idsync_state.status = "submitted"with the newlast_handle+ hash. - Mark outbox rows processed. On HTTP-level failure: bump attempts, mark affected sync_state rows
failed.
- 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.
Poll (BullMQ singleton repeating job)
- Registers alongside the drain with
repeat: { every: poll_interval_seconds * 1000 }. - Per tick:
- Stale handle sweep. Any pending handle older than
handle_poll_max_age_minutesis markedfailedwithpoll_timeout, and everysync_staterow attached to it flips tofailedwith the same reason. - Active poll. Read up to
handles_per_poll_tickpending handles in FIFO. For each:GET /{catalog_id}/check_batch_request_status?handle=…&fields=handle,status,ids_of_invalid_requests,errors_summary,data, wherecatalog_idis the handle's ownmeta_batch_handles.catalog_id— not the currently selected catalog. A handle only resolves inside the catalog it was submitted to, so polling by current selection would time out every in-flight batch after a catalog switch.- If
status === "in_progress": touchlast_polled_atand move on. - Otherwise: build a
retailer_id → errormap fromdata[].errors. Partition the handle's affectedsync_staterows ondelete_retailer_ids:- Rows with errors →
markFailedByVariant(status=failed, attempts++). - Rows without errors that were in
delete_retailer_ids→markDeleted(status=deleted). - Rows without errors that were upserts →
markSyncedByVariant(status=synced).
- Rows with errors →
- Mark the handle row
completedwitherrors_summarypersisted inerrors jsonb.
- On HTTP failure during poll: touch
last_polled_at(no retry counter — the next tick reads the same row).
- Stale handle sweep. Any pending handle older than
The delete_retailer_ids jsonb on each handle is what lets the poller route deletes to the right terminal status. Without it, every successful row would land as synced, even ones we deleted — which would pollute counts and the resync-failed bulk action.
Field mapping (variant → items_batch data)
Subset of Meta's product data spec we actively populate; anything undefined after assembly is pruned before send.
| Meta field | Source | Notes |
|---|---|---|
id | variant.id | Stable per-variant id; sent as the data.id (= retailer_id). |
title | product.title | Trimmed, capped at META_TITLE_MAX_LENGTH=200. Variants share the parent title — disambiguation is via item_group_id + dedicated attribute fields. |
description | product.description (or subtitle, or title) | HTML-stripped, whitespace-collapsed, capped at META_DESCRIPTION_MAX_LENGTH=9999. |
link | storefront_base_url + storefront_product_path | {slug} token URL-encoded and substituted. |
image_link | 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. |
additional_image_link | remaining images | Deduplicated, capped at 10. |
availability | inventory_stock | "in stock" if quantityOnHand − reservedQuantity > 0; "available for order" if zero + allowBackorder; "in stock" if trackInventory=false; else "out of stock". Spaces, not underscores — unlike Google's in_stock. |
price | variant.price | "<major>.<minor> CCY" (e.g. "299.00 INR"). Integer subunits / 100, two decimals. |
sale_price | variant.specialPrice (if < price and inside window) | Same format. |
sale_price_effective_date | variant.specialPriceStart/End | "<startISO>/<endISO>" — Meta's slash-separated interval syntax. Omitted when neither bound is set. |
condition | default_condition setting | new / refurbished / used (only three accepted by Meta). |
brand | brand.title → vendor.name → meta.business_name (if identifier_exists_fallback=true) | At-least-one of brand/gtin/mpn is required by Meta. |
gtin | variant.ean → variant.upc → variant.barcode | Digits-only, validated as GTIN-8/12/13/14. |
mpn | variant.sku | Trimmed. |
item_group_id | product.id | Groups all variants of one product (Meta caps at 100 variants per group). |
google_product_category | trail of category titles | Up to deepest 3 joined with >; Meta accepts the Google taxonomy verbatim. |
color / size / material / pattern | resolved variant options | Case-insensitive lookup on the option-name list loaded from product_option × product_option_value × variant_option_value. Synonyms: colour→color, fabric→material, print→pattern. |
custom_label_0 | vendor.name | For multi-vendor analytics in Meta Ads. |
custom_label_1 | 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 data payload. The drain compares this against sync_state.last_pushed_hash and skips the items_batch call when they match. A no-op repush is cheap: one SELECT + one outbox UPDATE.
Dedup is scoped by sync_state.catalog_id: a row describing a different catalog can neither suppress an upsert nor authorise a delete, since the catalog we now push to holds none of it. A NULL catalog_id predates the column and reads as the current catalog, so upgrading does not trigger a full re-push. This is what stops a catalog switch from leaving the new catalog silently empty — without it, "synced" would mean "synced somewhere".
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, BUC rate-limit, network) bump attempts for every row in the batch and surface in logs; the next drain tick retries.
Token model + refresher
The plugin registers a refresher against IntegrationCredentialsService at module bootstrap. Behaviour depends on the stored metadata.tokenKind:
"system_user"(after/select-catalog): no-op. System User tokens are non-expiring;accessTokenExpiresAt = nullis treated as "long-lived" by the integration service, so the refresher is never invoked."user"(between OAuth callback and/select-catalog): callsfb_exchange_tokenagain to mint a fresh 60-day token. TheaccessTokenExpiresAtfield drives the freshness buffer (default 60s before expiry).
IntegrationCredentialsService.getFreshAccessToken("meta_catalog") is single-flight per process — the integration module coalesces concurrent callers on a Map<string, Promise>.
Settings
All under admin scope, group meta. Read at call-time so edits via PATCH /admin/settings/admin apply on the next drain tick / OAuth start. The boot-time-only exceptions are sync_interval_seconds and poll_interval_seconds — they drive the BullMQ repeating job schedule, which is registered once at module init.
| Key | Type | Default | Notes |
|---|---|---|---|
app_id | string | "" | Meta App ID from developers.facebook.com |
app_secret | string | "" | SENSITIVE. Required for code→token exchange + fb_exchange_token |
redirect_uri | URL | "" | Falls back to ${PUBLIC_API_URL}/admin/meta/oauth/callback |
business_id | string | "" | Set automatically by /select-catalog |
business_name | string | "" | Cached display name (informational) |
catalog_id | string | "" | Set automatically by /select-catalog; the items_batch target |
catalog_name | string | "" | Cached display name (informational) |
system_user_id | string | "" | Set automatically by /select-catalog |
token_kind | enum | "user" | user / system_user; flipped by /select-catalog |
currency | ISO 4217 | "INR" | Stamped on every price / sale_price string |
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_condition | enum | "new" | new / refurbished / used |
identifier_exists_fallback | boolean | false | When true, synthesises a brand from business_name to satisfy Meta's "at least one of brand/gtin/mpn" rule |
sync_enabled | boolean | false | Master switch; outbox still accepts writes when off, drain skips |
sync_interval_seconds | int 10..3600 | 60 | Drain cadence. Restart API to apply changes. |
poll_interval_seconds | int 10..600 | 30 | Handle poll cadence. Restart API to apply changes. |
batch_size | int 1..5000 | 1000 | Outbox rows per drain tick. Hard-capped by META_ITEMS_BATCH_MAX=5000. |
handles_per_poll_tick | int 1..64 | 16 | Max pending handles polled per poll tick. |
max_attempts | int 1..20 | 5 | Per-outbox-row retry cap |
handle_poll_max_age_minutes | int 1..1440 | 30 | Pending handles older than this are marked poll_timeout-failed |
feed_mode | enum | "live" | live actually calls items_batch; preview short-circuits before HTTP and leaves sync_state untouched |
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.
Operational notes
- Operator-side first-time setup lives in the Meta setup guide — Meta App creation, App secrets, Facebook Login product, permissions, App Roles, admin-settings PATCH, OAuth handshake, business+catalog selection. This document covers the post-setup HTTP surface only.
- Rate limits. Meta enforces Business Use Case (BUC) limits per ad account per hour:
200 + 200 × log2(unique_users). The Graph wrapper surfaces theX-Business-Use-Case-Usageheader in debug logs on every call. With a single drain HTTP call persync_interval_seconds=60tick, the steady-state ceiling is well within BUC even with high churn. Bootstrap on a 100k-variant catalog is ~100k rows across ~20 batches — well under the limit. - Process model. The OAuth refresh coalescing assumes the Nest API + BullMQ workers 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 exceptions are
sync_interval_secondsandpoll_interval_seconds— read once at boot to register the BullMQ repeating jobs. Restart to pick up changes. - Changing the catalog. Repoint via
POST /select-catalog(the admin UI's catalog table), never by editingmeta.catalog_idin the settings screen. The settings path skips the purge, the state clear and the bootstrap — sync still converges, because dedup is catalog-scoped, but the outgoing catalog is orphaned with no way left to clean it up. - Eventually consistent live status.
GET /items/:variantId/meta-statusreads Meta's processed product view, which may lag the items_batch submit by minutes. Afound: falseshortly after a fresh push is expected, not a bug — wait one or two poll ticks and retry.
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 themetaadmin group.@sc/queue— provides the BullMQ infra the drain + poll processors consume.@sc/cache— backs the OAuth state nonce store (Redis with 10-minute TTL).@sc/admin-rbac— gates every endpoint viametaCatalog:view/metaCatalog:manage. See admin-rbac.md.
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,…
Navigation Module — Admin
HTTP surface for managing storefront navigation menus and the nested item tree inside each one. Menus are addressable by slug, read by the storefront, and this admin surface is the only writer.