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.

Scopes and rate limits

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_..."
Shell2 lines
Response
{  "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      }    ]  }}
JSON22 lines

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" }'
Shell4 lines
Response
{  "data": {    "status": "READY",    "productCount": 1280,    "defaultCurrency": "EUR",    "adapters": ["ga4", "jsonld"],    "lastVerifiedAt": "2026-06-16T10:00:00.000Z"  }}
JSON9 lines
400: unknown adapter
{  "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"  }}
JSON20 lines

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_..."
Shell2 lines
Response
{  "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 }}
JSON22 lines

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.

400: limit out of range
{  "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"  }}
JSON8 lines

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_..."
Shell2 lines
Response
{  "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    }  }}
JSON30 lines

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.

404: product not found
{  "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"  }}
JSON8 lines

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" }    ]  }'
Shell9 lines
Response (201)
{ "data": { "upserted": 1, "failed": 0, "errors": [] } }
JSON1 line

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": "":

Response (201): two of four products rejected
{  "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" }    ]  }}
JSON10 lines

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.

400: too many products in one call
{  "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"  }}
JSON20 lines

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" }'
Shell4 lines
Response
{ "data": { "sku": "SHOE-RED-42", "merged": { "sku": "SHOE-RED-42", "title": "Red Runner v2", "price": 8499, "currency": "USD" } } }
JSON1 line

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_..."
Shell2 lines
Response
{ "data": { "sku": "SHOE-RED-42", "deleted": true } }
JSON1 line

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 }'
Shell4 lines
Response
{ "data": { "ingestId": "ing_abc123", "uploadUrl": "https://...r2...presigned..." } }
JSON1 line

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_..."
Shell2 lines
Response
{ "data": { "ingestId": "ing_abc123", "accepted": true } }
JSON1 line
502: worker unreachable
{  "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"  }}
JSON8 lines
404: unknown ingest id
{  "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"  }}
JSON8 lines
409: already committed
{  "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"  }}
JSON9 lines

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.

statusMeaning
stagedThe upload URL was issued; the import has not been committed.
committingThe commit was accepted and the ingestion worker has not reported yet.
ingestedFinished. productsSeen is how many products this import wrote; error may note skipped rows.
failedIt 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_..."
Shell2 lines
Response
{  "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"  }}
JSON10 lines

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_..."
Shell2 lines
Response
{  "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  }}
JSON15 lines

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".

Was this helpful?