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
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 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 |
| 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 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
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"
}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 /{system_user_id}/assigned_product_catalogswithtasks=["MANAGE"]. Idempotent on Meta's side. - 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".
Response 200
{
"data": {
"systemUserId": "111222333444555",
"catalogId": "9876543210",
"tokenKind": "system_user"
}
}Errors
| Status | Code | When |
|---|---|---|
| 400 | meta_account_error | Meta refused System User creation or token mint |
| 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 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.- 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.
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
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 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. - 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,…
Notifications Module — Admin
HTTP surface for the admin notification operations: composing and sending broadcasts (one-shot email and/or push to a targeted audience) and inspecting the notification log…