Catalog Feed
The Catalog Feed API (GET /e9v3/catalog) serves the published B2B catalog — a
curated, quantity-free, orderable product feed — as a paginated snapshot. It is
designed for commerce consumers: static site generators (SSG), headless
storefronts, and any integration that needs a stable product list plus prices
without reading live inventory.
What it’s for
- Build product pages at deploy time from a stable snapshot (no live inventory load).
- Drive a public storefront or price list from the same data the B2B portal uses.
- Feed a search index or an external catalog (e.g. WooCommerce) by SKU.
Unlike GET /e9v3/inventory, the feed is a snapshot: it reflects the last
catalog publish, not real-time stock. Republish the catalog (or subscribe to the
rebuild webhooks below) to refresh it.
Authentication
- Scope:
inventory:read - Rate limit: standard (1 request/second per API key, burst 10)
Paging
The feed is chunked into batches of up to 500 items each. The version
object on every response tells you how many batches exist:
GET /e9v3/catalog?batch=0
{
"success": true,
"version": { "publishedAt": "2026-07-06T02:14:00Z", "itemCount": 1200, "batchCount": 3 },
"batch": 0,
"items": [ /* up to 500 items */ ],
"pricing": { "tierId": "wholesale", "version": 4, "currency": "AUD" }
}
Iterate batch = 0 .. version.batchCount - 1 to pull the whole feed. A
non-integer or negative batch returns 400; a batch at or beyond
version.batchCount returns 404 { "success": false, "error": "Batch out of range" }.
A business that has never published a catalog returns 404
{ "success": false, "error": "Catalog not published" }.
The effective-SKU join
Each item carries a parent sku and, for variant products, a variantSku:
{
"productId": "p_123",
"sku": "SHIRT",
"variantSku": "SHIRT-BLUE-L",
"name": "Cotton Shirt",
"variantName": "Blue / L",
"barcode": "9312345678900",
"category": "Apparel",
"uom": "ea",
"stockStatus": "in_stock",
"price": 24.5,
"gst": true
}
The effective SKU — the key you order and price against — is
variantSku when present and non-empty, otherwise sku. Simple (non-variant)
products omit variantSku/variantName entirely. This is the same convention
the order APIs use, so an effective SKU from the feed round-trips cleanly into
POST /e9v3/orders/send-matched.
Prices and the feed tier
Prices are opt-in per business. In the Console (Pricing → Public feed
tier) a business selects which price tier is exposed to the feed, stored as
feedTierId. When that tier is set and published:
- each item whose effective SKU is priced gets
price(ex-GST) andgst, and - a top-level
pricingblock names the tier:{ tierId, version, currency: "AUD" }.
When no feed tier is configured, or the selected tier has not been published,
price/gst and the pricing block are omitted — the feed is still fully
usable as a product catalog. Items whose effective SKU has no price in the tier
also omit price/gst.
Caching with ETags
Every response carries a strong ETag derived from the catalog publish time,
the merged price-tier version, and the batch index, plus Cache-Control: no-cache (always revalidate). Send the ETag back on the next request:
GET /e9v3/catalog?batch=0
If-None-Match: "a1b2c3…"
If the batch is unchanged you get 304 Not Modified with an empty body — cheap polling and cheap SSG rebuilds. The ETag changes whenever the catalog or the merged price tier is republished, so it is a reliable “did anything change” signal.
Rebuild webhooks
Rather than poll, subscribe to the publish events and re-pull the feed on change:
| Event | Fires when |
|---|---|
catalog.published |
The catalog snapshot is republished (products, names, categories, stock status). Payload: { businessId, itemCount?, batchCount? }. |
prices.published |
A feed price tier is republished. Payload: { businessId }. |
A common pattern is to wire catalog.published to a CI build hook so a static
storefront rebuilds automatically after every catalog publish. See the
Webhooks guide for subscription and signature
verification details.
Syncing to WooCommerce
Equos can push the published catalog outbound to a WooCommerce store, keeping its stock status and prices in step with what you publish — no polling, no code. This uses the same published snapshot as the feed above.
Direction & policy. This is a one-way, Equos-wins push. On every sync Equos overwrites the matched WooCommerce products’
stock_status(andregular_pricewhen a feed price tier is set);manage_stockis set tofalse. It never reads back from, nor creates products in, the store.
What syncs (v1)
- Stock status — mapped from the catalog’s
stockStatus:in_stock→instock,low→instock,out→outofstock. An unrecognized status is skipped for that product (its stock is left untouched — Equos will not blindly zero a live store) and counted in the result. - Price — only when a feed price tier is configured and published (see
Prices above). The WooCommerce
regular_priceis derived from the ex-GST catalog price per the connection’s price mode (below). - Quantity is not synced. The catalog is quantity-free, so
manage_stockstaysfalseand only stock status is written.
Matching & the variable-product limitation
Products are matched by SKU (effective SKU = variant SKU when present, else
parent SKU). SKUs with no matching WooCommerce product are reported, not
created — the result summary lists them (capped) under unmatchedEquosSkus.
Variable products (important for AU stores). v1 matches top-level product SKUs only. WooCommerce variation SKUs are separate objects and are not matched in this round — variations will show up as unmatched. The result includes a
hintfield flagging this. Variation-level matching is a planned follow-up.
Price mode & GST
Catalog prices are stored ex-GST. The connection’s priceMode controls the
regular_price written to WooCommerce:
priceMode |
regular_price written |
|---|---|
inclusive (default) |
ex-GST price + 10% GST on GST-flagged items (price × 1.10), rounded to 2 dp. Non-GST items are sent as-is. |
exclusive |
the raw ex-GST price, rounded to 2 dp. |
Most AU stores display GST-inclusive prices, so inclusive is the default. The
GST rate defaults to 0.10 and can be overridden per connection (gstRate).
Make sure this matches your store’s own tax settings so prices aren’t double-taxed.
When it runs
- Automatically whenever you publish the catalog (
catalog.published) or a feed price tier (prices.published). - Manually, by creating a
WooCommerceCatalogSyncjob (see below).
Setup (manual, this release)
There is no Console screen for this yet — configure it once by writing a Firestore
document at businessdata/{businessId}/integrations/woocommerce:
-
In WooCommerce, go to WooCommerce → Settings → Advanced → REST API and create a key with Read/Write permissions. Copy the consumer key and consumer secret.
-
Encrypt the consumer secret (recommended). The integrations document is readable by your own staff, so store the secret encrypted. Generate a key once and run the helper:
export WOO_TOKEN_ENCRYPTION_KEY="$(openssl rand -base64 32)" # 32-byte AES-256 key printf 'cs_your_consumer_secret' | go run ./integrations/cmd/woo-encrypt # → prints the base64 blob for consumerSecretEncryptedSet the same
WOO_TOKEN_ENCRYPTION_KEYin the worker’s environment so it can decrypt at sync time. -
Write the connection document:
{ "providerId": "woocommerce", "providerName": "WooCommerce", "enabled": true, "storeUrl": "https://store.example.com", "consumerKey": "ck_xxx", "consumerSecretEncrypted": "<blob from step 2>", "priceMode": "inclusive", // or "exclusive" "gstRate": 0.10, // optional; default 0.10 "syncStock": true, // optional; default true "syncPrice": true // optional; default true }Plaintext fallback. If you cannot run the helper, you may instead set
"consumerSecret": "cs_xxx"in plaintext. The worker will use it but logs a warning on every sync — prefer the encrypted field.
Set "enabled": false (or delete the document) to stop syncing.
Result summary
After each run the worker writes health and a summary back onto the same document:
lastSyncAt,lastSyncStatus(success|partial|failed),errorresult:{ updated, unmatchedCount, unmatchedEquosSkus[], skippedUnknownStatus, errors[], hint }
partial means some batch updates failed (details in result.errors); the run is
still recorded. Re-publishing the catalog or price tier re-syncs.