Supercommerce API Docs
Admin API

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 via CatalogMetaModule.forRoot() in apps/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 docs/meta/setup.md.


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 groupPermission
GET /status, GET /errors, GET /items, GET /items/:variantId, GET /items/:variantId/meta-status, GET /businessesmetaCatalog: 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/callbacknone — CSRF via state nonce

Response envelope

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

Error envelope

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR, meta_misconfigured, meta_oauth_state_invalid, meta_oauth_exchange_failed, meta_account_error
401UNAUTHORIZED
403FORBIDDEN
404NOT_FOUND (variant not in catalog)
500INTERNAL_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:

  1. 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."
  2. GET /{catalog_id}/check_batch_request_status?handle=… — polled until status="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 the Going Live section of docs/meta/setup.md.


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

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

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

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

StatusCodeWhen
400meta_oauth_state_invalidState missing / expired / replayed
400meta_oauth_exchange_failedFacebook refused the code; description includes the Graph error
400provider errorWhen ?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"
}

Atomic-ish sequence (the controller does not wrap these in a single transaction — each step is idempotent on retry):

  1. Ensure System User. Look up system_users in the chosen business by canonical name "Supercommerce Catalog Sync". If absent, create one with role ADMIN. If present, reuse its id.
  2. Assign catalog. POST /{system_user_id}/assigned_product_catalogs with tasks=["MANAGE"]. Idempotent on Meta's side.
  3. Mint System User access token. POST /{system_user_id}/access_tokens with business_app=app_id&scope=catalog_management,business_management. The set_token_expires_in_60_days parameter is intentionally omitted to mint a non-expiring token.
  4. Swap credential. Overwrite the integration_credentials row with the new token; clear accessTokenExpiresAt (null = "no expiry recorded" → the refresher will never fire); set metadata.tokenKind = "system_user".
  5. Persist settings. meta.business_id, meta.business_name, meta.catalog_id, meta.catalog_name, meta.system_user_id, meta.token_kind = "system_user".

Response 200

{
  "data": {
    "systemUserId": "111222333444555",
    "catalogId":    "9876543210",
    "tokenKind":    "system_user"
  }
}

Errors

StatusCodeWhen
400meta_account_errorMeta refused System User creation or token mint
500upstream Graph errorfbCode / 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 200MetaConnectionStatus.

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

NameTypeDefault
pageint >= 11
limitint 1..20050

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

NameTypeNotes
pageint >= 1Default 1
limitint 1..100Default 50
statusSyncStatus?synced / submitted / 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 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 200MetaItemDetail.

Errors

StatusCodeWhen
404NOT_FOUNDVariant 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 price Meta has stored ("299.00 INR").
  • The async review_status (drives "Available" / "Pending Review" / "Rejected" in Commerce Manager).
  • The array of validation errors Meta 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 200MetaItemLiveStatus.

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

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

TableOwnerPurpose
integration_credentials@sc/integrationOAuth tokens (provider meta_catalog). One row; metadata.tokenKind flips user → system_user after /select-catalog.
meta_sync_statethis moduleOne row per variant ever pushed (or skipped); PK variant_id. Carries the hash of the last pushed payload for dedup plus the in-flight last_handle so the poller can correlate per-item errors back to a variant. No FK to product_variant.
meta_outboxthis moduleAppend-only intent log (upsert | delete). Drained FIFO. Composite index (processed_at, enqueued_at) keeps the drain query cheap.
meta_batch_handlesthis moduleOne 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.

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 (catalog_id, currency, storefront_base_url) is incomplete.
    3. Read up to min(batch_size, META_ITEMS_BATCH_MAX=5000) outbox rows where processed_at IS NULL AND attempts < max_attempts, 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 items_batch data (method=UPDATE).
      • delete (variant deleted, product archived, or operator-triggered): method=DELETE with data.id = retailer_id.
      • 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. If feed_mode = "preview": log + mark outbox processed without HTTP submit. sync_state is not touched (so flipping preview off restores prior synced state intact).
    7. Otherwise: one POST /{catalog_id}/items_batch per tick (capped at META_ITEMS_BATCH_MAX=5000 rows). Cadence is governed by the repeating BullMQ schedule, not by fanning out concurrent calls — that's what burns BUC rate-limit budget.
    8. Per returned handle: persist a meta_batch_handles row including delete_retailer_ids (the subset of the chunk whose intent was DELETE). Mark every retailer_id sync_state.status = "submitted" with the new last_handle + hash.
    9. Mark outbox rows processed. On HTTP-level failure: bump attempts, mark affected sync_state rows failed.

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:
    1. Stale handle sweep. Any pending handle older than handle_poll_max_age_minutes is marked failed with poll_timeout, and every sync_state row attached to it flips to failed with the same reason.
    2. Active poll. Read up to handles_per_poll_tick pending handles in FIFO. For each:
      • GET /{catalog_id}/check_batch_request_status?handle=…&fields=handle,status,ids_of_invalid_requests,errors_summary,data.
      • If status === "in_progress": touch last_polled_at and move on.
      • Otherwise: build a retailer_id → error map from data[].errors. Partition the handle's affected sync_state rows on delete_retailer_ids:
        • Rows with errors → markFailedByVariant (status=failed, attempts++).
        • Rows without errors that were in delete_retailer_idsmarkDeleted (status=deleted).
        • Rows without errors that were upserts → markSyncedByVariant (status=synced).
      • Mark the handle row completed with errors_summary persisted in errors jsonb.
    3. On HTTP failure during poll: touch last_polled_at (no retry counter — the next tick reads the same row).

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 fieldSourceNotes
idvariant.idStable per-variant id; sent as the data.id (= retailer_id).
titleproduct.titleTrimmed, capped at META_TITLE_MAX_LENGTH=200. Variants share the parent title — disambiguation is via item_group_id + dedicated attribute fields.
descriptionproduct.description (or subtitle, or title)HTML-stripped, whitespace-collapsed, capped at META_DESCRIPTION_MAX_LENGTH=9999.
linkstorefront_base_url + storefront_product_path{slug} token URL-encoded and substituted.
image_linkvariant.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.
additional_image_linkremaining imagesDeduplicated, capped at 10.
availabilityinventory_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.
pricevariant.price"<major>.<minor> CCY" (e.g. "299.00 INR"). Integer subunits / 100, two decimals.
sale_pricevariant.specialPrice (if < price and inside window)Same format.
sale_price_effective_datevariant.specialPriceStart/End"<startISO>/<endISO>" — Meta's slash-separated interval syntax. Omitted when neither bound is set.
conditiondefault_condition settingnew / refurbished / used (only three accepted by Meta).
brandbrand.titlevendor.namemeta.business_name (if identifier_exists_fallback=true)At-least-one of brand/gtin/mpn is required by Meta.
gtinvariant.eanvariant.upcvariant.barcodeDigits-only, validated as GTIN-8/12/13/14.
mpnvariant.skuTrimmed.
item_group_idproduct.idGroups all variants of one product (Meta caps at 100 variants per group).
google_product_categorytrail of category titlesUp to deepest 3 joined with >; Meta accepts the Google taxonomy verbatim.
color / size / material / patternresolved variant optionsCase-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_0vendor.nameFor multi-vendor analytics in Meta Ads.
custom_label_1brand.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 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.

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 = null is treated as "long-lived" by the integration service, so the refresher is never invoked.
  • "user" (between OAuth callback and /select-catalog): calls fb_exchange_token again to mint a fresh 60-day token. The accessTokenExpiresAt field 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.

KeyTypeDefaultNotes
app_idstring""Meta App ID from developers.facebook.com
app_secretstring""SENSITIVE. Required for code→token exchange + fb_exchange_token
redirect_uriURL""Falls back to ${PUBLIC_API_URL}/admin/meta/oauth/callback
business_idstring""Set automatically by /select-catalog
business_namestring""Cached display name (informational)
catalog_idstring""Set automatically by /select-catalog; the items_batch target
catalog_namestring""Cached display name (informational)
system_user_idstring""Set automatically by /select-catalog
token_kindenum"user"user / system_user; flipped by /select-catalog
currencyISO 4217"INR"Stamped on every price / sale_price string
storefront_base_urlURL""Public storefront origin; required for link
storefront_product_pathpath template"/product/{slug}"{slug} is interpolated with URL-encoded product.slug
default_conditionenum"new"new / refurbished / used
identifier_exists_fallbackbooleanfalseWhen true, synthesises a brand from business_name to satisfy Meta's "at least one of brand/gtin/mpn" rule
sync_enabledbooleanfalseMaster switch; outbox still accepts writes when off, drain skips
sync_interval_secondsint 10..360060Drain cadence. Restart API to apply changes.
poll_interval_secondsint 10..60030Handle poll cadence. Restart API to apply changes.
batch_sizeint 1..50001000Outbox rows per drain tick. Hard-capped by META_ITEMS_BATCH_MAX=5000.
handles_per_poll_tickint 1..6416Max pending handles polled per poll tick.
max_attemptsint 1..205Per-outbox-row retry cap
handle_poll_max_age_minutesint 1..144030Pending handles older than this are marked poll_timeout-failed
feed_modeenum"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 docs/meta/setup.md — 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 the X-Business-Use-Case-Usage header in debug logs on every call. With a single drain HTTP call per sync_interval_seconds=60 tick, 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_seconds and poll_interval_seconds — read once at boot to register the BullMQ repeating jobs. Restart to pick up changes.
  • Eventually consistent live status. GET /items/:variantId/meta-status reads Meta's processed product view, which may lag the items_batch submit by minutes. A found: false shortly after a fresh push is expected, not a bug — wait one or two poll ticks and retry.

  • @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 meta admin 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 via metaCatalog:view / metaCatalog:manage. See admin-rbac.md.

On this page