Public API: Catalog
The Catalog endpoints manage a project's live product catalog: the product data A vs B stores and serves to recommendation experiments. Everything lives under /api/v1/projects/{projectId}/catalog. The org is taken from your token, so the path never includes an org id.
All endpoints follow the shared conventions: the { data } envelope, cursor pagination, Idempotency-Key on every write below, and the standard error shape.
Reads need catalog:read. Writes (config update, product upsert/delete, bulk import) need catalog:write. Every /api/v1 token is rate-limited the same way everywhere: a scoped token gets 600 reads and 120 writes per minute, and an admin:* token gets 600 requests per minute, reads and writes together. See Authentication for scopes and Conventions for the response headers that show your remaining budget.
Get catalog config
GET /api/v1/projects/{projectId}/catalog: the catalog status, configured adapters, default currency, and the source registry.
curl https://app.avsb.cloud/api/v1/projects/{projectId}/catalog \ -H "Authorization: Bearer avsb_svc_..."const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/catalog`const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` } })const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"res = requests.get( f"https://app.avsb.cloud/api/v1/projects/{project_id}/catalog", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]{ "data": { "catalog": { "status": "READY", "productCount": 1280, "defaultCurrency": "USD", "adapters": ["ga4", "jsonld"], "lastVerifiedAt": "2026-06-16T10:00:00.000Z" }, "sources": [ { "source": "push_api", "enabled": true, "status": "ok", "lastSyncAt": "2026-06-16T10:00:00.000Z", "lastFullSyncAt": null, "lastError": null, "productsSeen": 1280 } ] }}catalog is null and sources is an empty array when no catalog has been set up for the project yet. This endpoint never returns a 404 for that case.
Update catalog config
PATCH /api/v1/projects/{projectId}/catalog: toggle the opt-in adapters (ga4, jsonld, up to both) and the default currency (a 3-letter ISO-4217 code). Returns the updated config. Unknown fields in the body are rejected rather than ignored.
curl -X PATCH https://app.avsb.cloud/api/v1/projects/{projectId}/catalog \ -H "Authorization: Bearer avsb_svc_..." \ -H "Content-Type: application/json" \ -d '{ "adapters": ["ga4", "jsonld"], "defaultCurrency": "EUR" }'const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const res = await fetch(`https://app.avsb.cloud/api/v1/projects/${projectId}/catalog`, { method: 'PATCH', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ adapters: ['ga4', 'jsonld'], defaultCurrency: 'EUR' }),})const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"res = requests.patch( f"https://app.avsb.cloud/api/v1/projects/{project_id}/catalog", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"}, json={"adapters": ["ga4", "jsonld"], "defaultCurrency": "EUR"},)data = res.json()["data"]{ "data": { "status": "READY", "productCount": 1280, "defaultCurrency": "EUR", "adapters": ["ga4", "jsonld"], "lastVerifiedAt": "2026-06-16T10:00:00.000Z" }}{ "error": { "code": "validation_failed", "message": "Request body failed validation", "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#validation-errors", "details": { "issues": [ { "param": "adapters.1", "path": ["adapters", 1], "code": "invalid_enum_value", "message": "Invalid enum value. Expected 'ga4' | 'jsonld', received 'google_shopping'", "received": "google_shopping", "options": ["ga4", "jsonld"] } ] }, "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" }}Search products
GET /api/v1/projects/{projectId}/catalog/products: list products as stored, paginated. Optional q (free-text over sku + title) and exact sku filters. Uses cursor pagination (limit, cursor). Add filter to narrow to a data-quality bucket (out_of_stock, zero_price, missing_image, no_category), or category to one primary category. limit is validated up to 100 (20 by default), but the search itself caps a page at 50 rows. Ask for limit=100 and you still get at most 50 back; page.hasMore tells you there is more.
curl 'https://app.avsb.cloud/api/v1/projects/{projectId}/catalog/products?q=shoe&limit=20' \ -H "Authorization: Bearer avsb_svc_..."const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/catalog/products?q=shoe&limit=20`const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` } })const { data, page } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"res = requests.get( f"https://app.avsb.cloud/api/v1/projects/{project_id}/catalog/products", params={"q": "shoe", "limit": 20}, headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)body = res.json()data, page = body["data"], body["page"]{ "data": [ { "sku": "SHOE-RED-42", "title": "Red Runner", "href": "https://shop.example.com/shoe-red-42", "image": "https://shop.example.com/img/shoe-red-42.jpg", "priceMinor": 7999, "currency": "USD", "availability": "in_stock", "category": "Footwear/Running", "updatedAt": "2026-06-16T10:00:00.000Z", "sources": { "volatile": { "source": "push_api", "at": 1718532000000 }, "descriptive": { "source": "push_api", "at": 1718532000000 }, "media": { "source": "push_api", "at": 1718532000000 }, "taxonomy": { "source": "push_api", "at": 1718532000000 } } } ], "page": { "nextCursor": "eyJpZCI6ImNrLi4uIn0", "hasMore": true }}Pass page.nextCursor back as ?cursor=... to fetch the next page. Money is in ISO-4217 minor units (priceMinor). sources names which source last touched each of four field groups, and when: price/availability/stock, title/description/href/brand, image, and category/custom fields.
{ "error": { "code": "pagination_invalid", "message": "limit must be an integer between 1 and 100", "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#cursor-pagination", "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" }}Inspect one product
GET /api/v1/projects/{projectId}/catalog/products/{sku}: the full stored record for one product. Returns 404 when the sku doesn't exist or has been removed.
curl https://app.avsb.cloud/api/v1/projects/{projectId}/catalog/products/SHOE-RED-42 \ -H "Authorization: Bearer avsb_svc_..."const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const sku = 'SHOE-RED-42'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/catalog/products/${sku}`const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` } })const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"sku = "SHOE-RED-42"res = requests.get( f"https://app.avsb.cloud/api/v1/projects/{project_id}/catalog/products/{sku}", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]{ "data": { "sku": "SHOE-RED-42", "title": "Red Runner", "href": "https://shop.example.com/shoe-red-42", "image": "https://shop.example.com/img/shoe-red-42.jpg", "priceMinor": 7999, "currency": "USD", "availability": "in_stock", "category": "Footwear/Running", "updatedAt": "2026-06-16T10:00:00.000Z", "sources": { "volatile": { "source": "push_api", "at": 1718532000000 }, "descriptive": { "source": "push_api", "at": 1718532000000 }, "media": { "source": "push_api", "at": 1718532000000 }, "taxonomy": { "source": "push_api", "at": 1718532000000 } }, "record": { "sku": "SHOE-RED-42", "title": "Red Runner", "href": "https://shop.example.com/shoe-red-42", "availability": "in_stock", "price": 7999, "currency": "USD", "sources": { "volatile": { "source": "push_api", "at": 1718532000000 } }, "updatedAt": 1718532000000, "firstSeenAt": 1718500000000 } }}record is the complete stored record behind the flatter fields above it. It carries the same four sources keys shown above; only one is shown here to save space. Its own price is already in minor units, unlike the price field you send when writing a product.
{ "error": { "code": "not_found", "message": "Product not found in catalog", "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#not-found-errors", "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" }}Add or update products (inline)
POST /api/v1/projects/{projectId}/catalog/products: upsert up to 1000 products in one call. Per-product failures are reported without aborting the batch. Supports Idempotency-Key.
Every field except sku is optional. Send price/compareAtPrice in major units (as displayed) or priceMinor/compareAtPriceMinor in minor units; the minor field wins when both are present. Products also accept brand, categories (an array), stock, variants (an array of variantSku, price and stock), a per-currency prices map for selling in more than one currency, and up to 40 customFields.
curl -X POST https://app.avsb.cloud/api/v1/projects/{projectId}/catalog/products \ -H "Authorization: Bearer avsb_svc_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "products": [ { "sku": "SHOE-RED-42", "title": "Red Runner", "price": 79.99, "currency": "USD", "url": "https://shop.example.com/shoe-red-42" } ] }'const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const res = await fetch(`https://app.avsb.cloud/api/v1/projects/${projectId}/catalog/products`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ products: [ { sku: 'SHOE-RED-42', title: 'Red Runner', price: 79.99, currency: 'USD', url: 'https://shop.example.com/shoe-red-42' }, ], }),})const { data } = await res.json()import os, uuid, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"res = requests.post( f"https://app.avsb.cloud/api/v1/projects/{project_id}/catalog/products", headers={ "Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}", "Idempotency-Key": str(uuid.uuid4()), }, json={"products": [{"sku": "SHOE-RED-42", "title": "Red Runner", "price": 79.99, "currency": "USD", "url": "https://shop.example.com/shoe-red-42"}]},)data = res.json()["data"]{ "data": { "upserted": 1, "failed": 0, "errors": [] } }A product that fails validation doesn't fail the whole call: the valid products are saved, and each invalid one is counted in failed and named in errors with its own reason. The reason starts with the product's position in your list, and a product sent without a sku is reported with "sku": "":
{ "data": { "upserted": 2, "failed": 2, "errors": [ { "sku": "", "reason": "products[2].sku: Required" }, { "sku": "SHOE-BLUE-40", "reason": "products[3].price: Number must be greater than or equal to 0" } ] }}Only the request itself can be refused as a whole: no products list, an empty one, or more than 1000 products.
When you send categories without category, the first entry of categories becomes the product's category in reads and filters.
{ "error": { "code": "validation_failed", "message": "Request body failed validation", "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#validation-errors", "details": { "issues": [ { "param": "products", "path": ["products"], "code": "too_big", "message": "Send ≤1000 products inline; use the staged bulk endpoint for larger imports", "limit": 1000, "expected": "array" } ] }, "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" }}Update one product
PUT /api/v1/projects/{projectId}/catalog/products/{sku}: upsert a single product. The {sku} in the path always wins over any sku in the body. Supports Idempotency-Key.
curl -X PUT https://app.avsb.cloud/api/v1/projects/{projectId}/catalog/products/SHOE-RED-42 \ -H "Authorization: Bearer avsb_svc_..." \ -H "Content-Type: application/json" \ -d '{ "title": "Red Runner v2", "price": 84.99, "currency": "USD" }'const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const sku = 'SHOE-RED-42'const res = await fetch(`https://app.avsb.cloud/api/v1/projects/${projectId}/catalog/products/${sku}`, { method: 'PUT', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Red Runner v2', price: 84.99, currency: 'USD' }),})const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"sku = "SHOE-RED-42"res = requests.put( f"https://app.avsb.cloud/api/v1/projects/{project_id}/catalog/products/{sku}", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"}, json={"title": "Red Runner v2", "price": 84.99, "currency": "USD"},)data = res.json()["data"]{ "data": { "sku": "SHOE-RED-42", "merged": { "sku": "SHOE-RED-42", "title": "Red Runner v2", "price": 8499, "currency": "USD" } } }A malformed field (a negative price, a description over 10000 characters, and so on) returns the same 400 validation_failed shape shown under Add or update products above.
Delete one product
DELETE /api/v1/projects/{projectId}/catalog/products/{sku}: tombstone a product so it stops serving. Idempotent: deleting an absent sku returns deleted: false rather than an error. Supports Idempotency-Key.
curl -X DELETE https://app.avsb.cloud/api/v1/projects/{projectId}/catalog/products/SHOE-RED-42 \ -H "Authorization: Bearer avsb_svc_..."const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const sku = 'SHOE-RED-42'const res = await fetch(`https://app.avsb.cloud/api/v1/projects/${projectId}/catalog/products/${sku}`, { method: 'DELETE', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` },})const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"sku = "SHOE-RED-42"res = requests.delete( f"https://app.avsb.cloud/api/v1/projects/{project_id}/catalog/products/{sku}", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]{ "data": { "sku": "SHOE-RED-42", "deleted": true } }Bulk import (large catalogs)
For imports above 1000 products, use the two-step staged flow: stage to get a presigned upload URL, stream your data, then commit.
1. Stage
POST /api/v1/projects/{projectId}/catalog/products/bulk: returns an ingestId and a presigned R2 uploadUrl. Responds 202. Supports Idempotency-Key. count is optional and advisory only, an estimate for progress display; it isn't checked against what you actually upload.
curl -X POST https://app.avsb.cloud/api/v1/projects/{projectId}/catalog/products/bulk \ -H "Authorization: Bearer avsb_svc_..." \ -H "Content-Type: application/json" \ -d '{ "count": 5000 }'const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const res = await fetch(`https://app.avsb.cloud/api/v1/projects/${projectId}/catalog/products/bulk`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ count: 5000 }),})const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"res = requests.post( f"https://app.avsb.cloud/api/v1/projects/{project_id}/catalog/products/bulk", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"}, json={"count": 5000},)data = res.json()["data"]{ "data": { "ingestId": "ing_abc123", "uploadUrl": "https://...r2...presigned..." } }Then PUT your products as NDJSON (one JSON product per line) to uploadUrl.
2. Commit
POST /api/v1/projects/{projectId}/catalog/products/bulk/{ingestId}/commit: confirm the upload and start ingestion. Responds 202 on accept; 502 if the ingestion worker is unreachable (safe to retry with the same ingestId). Supports Idempotency-Key.
The ingestId must be the one the stage call returned for this project. An id that was never staged, or one replaced by a newer stage, returns 404. Committing an import that is already running or finished returns 409 dataset_state_conflict with details.reason: "already_committed". An import that failed can be committed again with the same id. After a 202, follow the import with the status call below.
curl -X POST https://app.avsb.cloud/api/v1/projects/{projectId}/catalog/products/bulk/ing_abc123/commit \ -H "Authorization: Bearer avsb_svc_..."const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const ingestId = 'ing_abc123'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/catalog/products/bulk/${ingestId}/commit`const res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` },})const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"ingest_id = "ing_abc123"res = requests.post( f"https://app.avsb.cloud/api/v1/projects/{project_id}/catalog/products/bulk/{ingest_id}/commit", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]{ "data": { "ingestId": "ing_abc123", "accepted": true } }{ "error": { "code": "internal_error", "message": "Bulk ingest commit could not be forwarded to the worker", "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#server-errors", "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" }}{ "error": { "code": "not_found", "message": "No staged import with this id. Stage a new import and commit the id it returns.", "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#not-found-errors", "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" }}{ "error": { "code": "dataset_state_conflict", "message": "This import was already committed. Check its progress with GET .../catalog/products/bulk/{ingestId}.", "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#conflict-errors", "details": { "reason": "already_committed", "status": "committing" }, "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" }}3. Check progress
GET /api/v1/projects/{projectId}/catalog/products/bulk/{ingestId}: where a staged import is. Needs catalog:read. Returns 404 for an id this project never staged.
status | Meaning |
|---|---|
staged | The upload URL was issued; the import has not been committed. |
committing | The commit was accepted and the ingestion worker has not reported yet. |
ingested | Finished. productsSeen is how many products this import wrote; error may note skipped rows. |
failed | It did not finish, or the worker did not report within 30 minutes. error says why. Commit the same ingestId again to retry. |
curl https://app.avsb.cloud/api/v1/projects/{projectId}/catalog/products/bulk/ing_abc123 \ -H "Authorization: Bearer avsb_svc_..."const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const ingestId = 'ing_abc123'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/catalog/products/bulk/${ingestId}`const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` } })const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"ingest_id = "ing_abc123"res = requests.get( f"https://app.avsb.cloud/api/v1/projects/{project_id}/catalog/products/bulk/{ingest_id}", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]{ "data": { "ingestId": "ing_abc123", "status": "ingested", "productsSeen": 4997, "error": "3 rows skipped", "committedAt": "2026-06-16T10:00:00.000Z", "finishedAt": "2026-06-16T10:03:12.000Z" }}Poll every 30 seconds or so. If the ingestion worker has not reported on an import 30 minutes after the commit, the status turns to failed and error says so. Commit the same ingestId again, and contact support if it stalls twice.
Coverage roll-up
GET /api/v1/projects/{projectId}/catalog/coverage: the "% of recommended SKUs with catalog data" health metric plus freshness signals. Returns 404 when the project has no catalog.
curl https://app.avsb.cloud/api/v1/projects/{projectId}/catalog/coverage \ -H "Authorization: Bearer avsb_svc_..."const projectId = 'cm1a2b3c4d5e6f7g8h9i0j1k2'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/catalog/coverage`const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` } })const { data } = await res.json()import os, requestsproject_id = "cm1a2b3c4d5e6f7g8h9i0j1k2"res = requests.get( f"https://app.avsb.cloud/api/v1/projects/{project_id}/catalog/coverage", headers={"Authorization": f"Bearer {os.environ['AVSB_SERVICE_TOKEN']}"},)data = res.json()["data"]{ "data": { "coveragePercent": 92.5, "recommendedItems": 40, "itemsWithCatalogData": 37, "droppedNoCatalog": 3, "recipesCounted": 2, "recipesEnabled": 2, "catalogProductCount": 1280, "computedFrom": "last_run", "stalestSourceAt": "2026-06-15T00:00:00.000Z", "liveVsCatalogStale": false, "recentProductViews": 312 }}A project with no catalog yet returns the same 404 not_found shape shown under Inspect one product above, worded "No catalog for this project".