Products API

The Products API covers a tenant's full catalog: search and listing, product detail, create/edit/delete, variants, media, automotive compatibility (fitment), materials/supplies/BOM/modifiers (manufacturing and POS), bulk operations (rollback, visual flow editing, AI mutations), CSV import/export, categorization, and publishing to marketplaces, collections, and categories.

Read this before any other article in this section. Unlike other domains in this API, there is no single response contract here — that's the single most important finding in the whole domain, and it shapes how you should read every article that follows.

Domain architecture

"Products" in this API is actually three independent lambdas behind three separate route bases, each with its own authorization scheme:

Route baseCovers
/products/...Catalog, detail, CRUD, variants, media, fitment, materials/BOM, bulk, CSV, channel publishing
/collections/...Product collections (manual/dynamic groupings)
/categories/...Own category tree + AI-assisted classification for SAT, T1, Walmart, TikTok, and mappings to marketplaces

All of them require authentication; each lambda's internal routing is done by its own code via path/method, not by API Gateway.

Base URL and authentication

https://api.fenicia.io

There is no version prefix (/v1) in the URL. Every request requires an API key sent as a Bearer token:

Authorization: Bearer fkapi_your_api_key

Tip

Store your API key in an environment variable (FENICIA_API_KEY) and never include it directly in source code.

⚠️ There is no single response envelope

In the Orders domain, every response follows {data, meta} / {error}. That is not true in Products. Only one endpoint in the whole domain uses that envelope; the rest respond with ad-hoc shapes per endpoint — some wrapped, some not wrapped at all, some as a flat array.

Verify the shape in each article — never assume it

There is no general rule you can apply to a new endpoint in this domain. The response shape is documented case by case in each article of this section.

Real examples, so you can calibrate the variety:

Endpoint (example)200/201 response shape
GET /products?availableAt=<locationId>{ "data": Product[], "meta": { "pagination": {...} } } — the only case with this envelope
GET /products (without availableAt)Product[] — flat array, no wrapper (legacy shape)
GET /products/count{ "count": number } or { "count": number, "searchStrategy": string } if there's a term
GET /products/exist/{sku}{ "exist": boolean }
POST /products/query{ "products": [...], "total": number, "page": number, "limit": number }
GET /products/{id}Product — direct object, no wrapper
POST /products / PUT /products/{id}Product — direct object, no wrapper (201 / 200)
DELETE /products/{id}{ "success": true, "message": "<string>" }
Async operations (CSV, channel export, scheduled bulk)202 + { "jobId": "..." } (or { "scheduleId": "..." })
Bulk operations with partial success207 + endpoint-specific result, typically with a results[]/errors[] array per item
Only case with {data, meta}: GET /products?availableAt=
{
  "data": [
    { "sku": "CAM-ROJO-M", "title": { "value": "Camisa Roja Talla M" }, "price": 499.0, "status": "active" }
  ],
  "meta": {
    "pagination": { "page": 0, "limit": 20, "total": 143, "totalPages": 8, "hasMore": true }
  }
}
The same listing WITHOUT availableAt: flat array
[
  { "sku": "CAM-ROJO-M", "title": { "value": "Camisa Roja Talla M" }, "price": 499.0, "status": "active" }
]

See Catalog and search for the full detail on GET /products and its two shapes.

Pagination

page and limit are the pagination parameters across the entire listing surface. The base is consistent (page starts at 0), but the default and ceiling of limit vary by endpoint — there is no single global value:

  • page: integer, base 0 across all listing endpoints.
  • limit: the most common default in lambda-products is 20; the underlying library's validator (validatePagination) uses 50 as a generic default and applies a ceiling of 500 on the vast majority of routes.
  • Ceiling exception: async CSV export uses EXPORT_MAX_LIMIT = 10000, far above the rest of the domain.

Confirm the exact default and maximum in each endpoint's article before assuming them — Catalog and search documents those of GET /products, and Bulk CSV documents those of import/export.

Projection with extend

GET /products/{id} (and, at the library level, the rest of the listing endpoints) supports ?extend= to request fields that don't come by default in the base projection (the default response weighs ~3 KB per product; without extend, several heavy fields are intentionally omitted).

extend valueWhat it adds
variantsFull variants array
optionsVariation options (size, color, etc.)
descriptionLong / HTML description
bindingsSales channel links
metadataMetafields
inventoryInventory per variant and location (live join against the inventory collection)
dimensionsShipping dimensions
seometaTitle, metaDescription, slug
allEverything above

You can combine several comma-separated values: ?extend=variants,inventory,seo.

`embeddings` exists at the library level, not confirmed on this endpoint

The underlying library defines embeddings as an additional extendable field (alongside the ones in the table). This audit did not confirm that GET /products/{id} accepts it in its query parsing — don't assume it without testing first.

Full response detail with each value in Retrieve a product.

Permissions

The permission catalog lives in @fenicia/core. Each route base uses its own namespace, with one notable exception:

PRODUCTS.READ    products:read      PRODUCTS.CREATE   products:create
PRODUCTS.UPDATE  products:update    PRODUCTS.DELETE   products:delete
PRODUCTS.IMPORT  products:import    PRODUCTS.EXPORT   products:export
PRODUCTS.SYNC    products:sync      PRODUCTS.MANAGE   products:manage
PRODUCTS.ALL     products:*

COLLECTIONS.* follows the same pattern (READ/CREATE/UPDATE/DELETE) for /collections/....

/categories/... does NOT have its own permission namespace

Despite being a separate lambda and route base, /categories/... reuses PERMISSIONS.PRODUCTS.* (mostly READ and MANAGE) plus SETTINGS.READ/SETTINGS.MANAGE for its stats and cache-administration endpoints. There is no PERMISSIONS.CATEGORIES.*.

Stacked RBAC — some routes require two permissions, not one

Most endpoints validate a single permission. A subset requires two, stacked, and there is a documented asymmetry between dev and production:

RouteRequired permission(s)
/products/export*PRODUCTS.EXPORT + PRODUCTS.READ
GET /products/bulk/operations*PRODUCTS.READ + PRODUCTS.UPDATE
GET /products/bulk/schedule* (dev only, not yet in master)PRODUCTS.UPDATE alone — does not require READ, unlike the rest of the domain's GET routes

Don't assume "one permission per route" when integrating: confirm in the specific article (Channel publishing, Bulk operations) whether the route you're calling requires more than one.

Four /categories/... routes without explicit requirePermission()

walmart/unified-classify, cache/lookup, cache/stats, and cache/invalidate only validate that a tenantId exists in the session — they do not check an explicit RBAC permission, unlike almost the rest of the file. This could be intentional (internal use) or an oversight; treat it as a known quirk, not as the domain's general pattern.

Irreversible operations

Deleting a product is a hard delete

DELETE /products/{id} permanently removes the document from the database — there is no trash can or soft-delete at this endpoint's level. status: 'disabled' is an independent logical state and not equivalent to deleting it. Before calling this endpoint from your integration, confirm you really want an unrecoverable deletion. Detail in Manage products.

Known limits of this API version

  • No granular endpoints for attributes/custom fields, SEO, or price schemas: all of that is read via ?extend= but only written by resending the full product payload via PUT /products/{id}. See Manage products.
  • No endpoints to delete or reorder media: the full media[] array is rewritten via PUT /products/{id}. See Variants and media.
  • No HTTP "bundle" entity exists: the only product-of-products composition mechanism with its own endpoints is BOM (oriented to manufacturing/consumption). bundleConfig is a data sub-shape managed inside the product payload, with no /products/{sku}/bundle routes. See Data model and Materials and BOM.

Quick example

curl "https://api.fenicia.io/products?limit=5" \
  -H "Authorization: Bearer $FENICIA_API_KEY"

Section map

ArticleContent
Catalog and searchGET /products, search, count, available filters.
Retrieve a productDetail by ID/SKU, ?extend=, related products, a product's variants.
Data modelReal (Mongoose) shape of Product and ProductVariant, divergences from the public TS type.
Manage productsCreate, update, delete, SKU rename.
Variants and mediaUpdate fields on an existing variant; upload images/media.
Fitment compatibilityCatalogs and compatibility nodes for the automotive vertical.
Materials and BOMMaterials, supplies, bill of materials (BOM), and food-delivery style modifiers.
Bulk operationsBulk operation rollback, visual flow editor, AI mutations.
Bulk CSVCSV import/export, sync and async, templates.
Channel publishingMarketplace categorization, export/publishing to channel, T1 enrichment.
CollectionsProduct groupings (/collections/...).
CategoriesOwn tree, SAT/T1/Walmart/TikTok classification, mappings to marketplaces (/categories/...).
Error catalogAll known error codes in the domain, grouped by their real origin.

Next steps