Catalog Push API
The Push API is a recommended primary path to a complete catalog: you control exactly what goes in, with full coverage and zero shopper-browser cost.
The Push API lets you send product data to the Live Catalog from any server-side process: your own backend, a build pipeline, a custom integration, or a scheduled job. It is the right choice when you want direct control over what goes in the catalog and when.
All Push API routes authenticate with an organization service token. Create one under Organization Settings → Service Tokens, and give it the catalog:write scope (add catalog:read if you also want to read products back). Service tokens start with avsb_svc_.
Authorization: Bearer avsb_svc_your_service_tokenEvery route below is on the public API at /api/v1. The {projectId} in each path accepts your project's numeric id, the number you see in the dashboard URL (/projects/42/...), so 42 works.
A service token grants API access to your whole organization, scoped to the permissions you gave it. Keep it server-side, in an environment variable. Never put it in browser code, a mobile app, or the A vs B snippet.
Every route here is a write, so a scoped token is limited to 120 write requests per minute (reads elsewhere in the API have their own, higher limit). An admin:* token gets 600 requests per minute, reads and writes together. Over the limit, you get 429 Too Many Requests with X-RateLimit-* headers showing the ceiling, what's left, and when it resets. Use the staged bulk endpoint for large one-shot imports instead of many rapid small requests.
Every route also accepts an optional Idempotency-Key header: send the same key with the same body and A vs B returns the original result instead of writing twice, so a retried request after a timeout is safe. Full behaviour in API conventions.
Product fields
Every product you push can include the following fields:
| Field | Type | Notes |
|---|---|---|
sku | string (required in bulk) | Your stable product identifier. Required in the bulk body; in a single-product PUT it comes from the URL path. Max 512 characters. |
title | string | Product display name. Max 1,000 characters. |
description | string | Full product description. Max 10,000 characters. Used by the Similar products algorithm. |
url | string | Canonical product page URL. Max 2,048 characters. |
image | string | Main product image URL. Max 2,048 characters. |
brand | string | Manufacturer or brand name. Max 500 characters. |
category | string | Primary product type or category. Max 500 characters. |
categories | string[] | Additional categories (up to 50). |
price | number | Price in major units (e.g. 89.00 for $89.00). A vs B converts to minor units using your project currency. |
priceMinor | integer | Price in minor units (e.g. 8900 for $89.00). Wins over price when both are present. |
compareAtPrice | number | Original/was price in major units (for sale display). |
compareAtPriceMinor | integer | Original/was price in minor units. Wins over compareAtPrice when both are present. |
currency | string | ISO 4217 currency code (e.g. "USD"). Defaults to your project currency. |
prices | object | Per-currency prices map: keys are ISO 4217 codes, values are prices in major units (e.g. {"USD": 89.00, "GBP": 74.99}). A vs B converts each to minor units. |
availability | string | One of: in_stock, out_of_stock, preorder, removed. Defaults to in_stock if you don't send it. |
stock | integer | Total inventory quantity (non-negative). |
variants | array | Variant list: see below. Up to 200 variants per product. |
customFields | object | Arbitrary key-value pairs. Up to 40 keys. Values can be string, number, or boolean. |
createdAt | integer | Product creation timestamp in milliseconds since epoch. Used to power "new arrivals" sorting in recommendations. |
Variant fields
Each object in the variants array can include:
| Field | Type | Notes |
|---|---|---|
variantSku | string (required) | Unique identifier for this variant. Max 512 characters. |
title | string | Variant display name (e.g. "Blue / Large"). Max 1,000 characters. |
options | object | Option name to value map (e.g. {"color": "Blue", "size": "L"}). |
price | number | Variant price in major units. |
priceMinor | integer | Variant price in minor units. Wins over price. |
stock | integer | Variant-level inventory quantity. |
availability | string | Variant-level availability. Defaults to in_stock if you don't send it. |
image | string | Variant-specific image URL. Max 2,048 characters. |
Minor units are the smallest denomination of a currency. For USD, GBP, EUR, and most others, 1 major unit = 100 minor units ($89.00 = 8900). Japanese yen has no minor unit (1 JPY = 1 JPY). Kuwaiti dinar has 3 decimal places (1 KWD = 1000 fils). A vs B uses your project's currency setting to know the right exponent, so you can always send price in major units and let A vs B handle the conversion.
Routes
Bulk upsert (up to 1,000 products)
Use this for regular syncs: nightly jobs, post-deploy catalog refreshes, or any batch up to 1,000 products.
POST /api/v1/projects/{projectId}/catalog/productssku is the only required field per product (a request of { "products": [{ "sku": "MERINO-NAVY-M" }] } is valid on its own); everything else is optional and only overwrites what you send. A real sync sends more than a bare sku. This fuller request shows the optional fields you'll use most often:
curl -X POST https://app.avsb.cloud/api/v1/projects/42/catalog/products \ -H "Authorization: Bearer avsb_svc_your_service_token" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "products": [ { "sku": "MERINO-NAVY-M", "title": "Merino Wool Sweater", "url": "https://shop.example.com/products/merino-sweater", "image": "https://shop.example.com/img/merino-navy.jpg", "brand": "Example Brand", "category": "Knitwear", "priceMinor": 8900, "currency": "USD", "availability": "in_stock", "stock": 42 } ] }'const projectId = 42const product = { sku: 'MERINO-NAVY-M', title: 'Merino Wool Sweater', // shown in recommendation cards url: 'https://shop.example.com/products/merino-sweater', // where a click should land image: 'https://shop.example.com/img/merino-navy.jpg', brand: 'Example Brand', category: 'Knitwear', // powers per-category recommendation rules priceMinor: 8900, // minor units win over `price` when both are sent currency: 'USD', availability: 'in_stock', stock: 42,}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: [product] }),})const { data } = await res.json()import os, uuid, requestsproject_id = 42product = { "sku": "MERINO-NAVY-M", "title": "Merino Wool Sweater", "url": "https://shop.example.com/products/merino-sweater", "image": "https://shop.example.com/img/merino-navy.jpg", "brand": "Example Brand", "category": "Knitwear", "priceMinor": 8900, # minor units win over "price" when both are sent "currency": "USD", "availability": "in_stock", "stock": 42,}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": [product]},)data = res.json()["data"]Response (201):
{ "data": { "upserted": 1, "failed": 0, "errors": [] }}A product that fails partway through doesn't fail the whole call: it is counted in failed and named in errors, each with its own sku and reason, and every other product in the batch still gets written. A partial success (some upserted, some failed) still returns 201 with non-zero failed, the same status as a full success.
Sending more than 1,000 products in one call fails the whole request instead, with a 400:
{ "error": { "code": "validation_failed", "message": "Request body failed validation", "details": { "issues": [ { "param": "products", "path": ["products"], "code": "too_big", "message": "Send ≤1000 products inline; use the staged bulk endpoint for larger imports", "limit": 1000 } ] }, "docUrl": "https://docs.avsb.cloud/docs/developer-reference/public-api/conventions#validation-errors", "requestId": "req_9f2c41ab7e0b4d1e8c35a6f0d2b91e77" }}Scope: catalog:write. Rate limit: 120 requests per minute per token, shared with every other write route on this page.
Single product upsert
PUT /api/v1/projects/{projectId}/catalog/products/{sku}The {sku} in the URL path is the authoritative identifier: any sku field in the request body is ignored in favour of the path parameter. URL-encode the SKU if it contains slashes or spaces.
Request body: a single product object (all fields optional except those you want to set).
curl -X PUT https://app.avsb.cloud/api/v1/projects/42/catalog/products/MERINO-NAVY-M \ -H "Authorization: Bearer avsb_svc_your_service_token" \ -H "Content-Type: application/json" \ -d '{"title":"Merino Wool Sweater","priceMinor":8900,"currency":"USD","availability":"in_stock","url":"https://shop.example.com/products/merino-sweater"}'const projectId = 42const sku = 'MERINO-NAVY-M'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/catalog/products/${sku}`const body = { title: 'Merino Wool Sweater', priceMinor: 8900, currency: 'USD', availability: 'in_stock' }const res = await fetch(url, { method: 'PUT', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body),})const { data } = await res.json()Response:
{ "data": { "sku": "MERINO-NAVY-M", "merged": { "sku": "MERINO-NAVY-M", "title": "Merino Wool Sweater", "href": "https://shop.example.com/products/merino-sweater", "price": 8900, "currency": "USD", "availability": "in_stock" } }}merged is the normalised incoming record after applying the freshest-wins merge rules: price here is already in minor units, and url becomes href. Scope: catalog:write. A malformed field (a negative price, a description over 10,000 characters, and so on) returns the same 400 validation_failed shape shown under Bulk upsert above.
Remove a product
DELETE /api/v1/projects/{projectId}/catalog/products/{sku}No request body is required. Marks the product as removed in the Live Catalog: it is excluded from recommendations and commerce audiences but kept in the catalog history.
curl -X DELETE https://app.avsb.cloud/api/v1/projects/42/catalog/products/MERINO-NAVY-M \ -H "Authorization: Bearer avsb_svc_your_service_token"const projectId = 42const sku = 'MERINO-NAVY-M'const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/catalog/products/${sku}`const res = await fetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}` },})const { data } = await res.json()Response:
{ "data": { "sku": "MERINO-NAVY-M", "deleted": true }}Deleting a SKU that does not exist is a no-op and returns deleted: false. It is not an error. Scope: catalog:write.
Staged bulk flow (more than 1,000 products)
For full catalog imports (initial loads, large refreshes, or catalogs with tens of thousands of products) use the staged bulk flow. It avoids request-size limits by uploading the data directly to object storage, then triggering processing. A staged import can hold up to 1,000,000 product lines, and no single line can be larger than 64KB.
Step 1: Stage the upload
POST /api/v1/projects/{projectId}/catalog/products/bulkRequest body (optional):
{ "count": 45000 }The count field is advisory: you do not need to send it, and A vs B does not validate the actual upload against it. Include it if you want it reflected in the dashboard status.
curl -X POST https://app.avsb.cloud/api/v1/projects/42/catalog/products/bulk \ -H "Authorization: Bearer avsb_svc_your_service_token" \ -H "Content-Type: application/json" \ -d '{ "count": 45000 }'const projectId = 42const url = `https://app.avsb.cloud/api/v1/projects/${projectId}/catalog/products/bulk`const res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${process.env.AVSB_SERVICE_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ count: 45000 }),})const { data } = await res.json()Response (202):
{ "data": { "ingestId": "ingest_abc123", "uploadUrl": "https://r2.example.com/..." }}Step 2: Upload the NDJSON file
Send an HTTP PUT directly to the uploadUrl with your product catalog as NDJSON: one JSON product object per line. Each line should include a sku field; a line with no usable sku is skipped and counted rather than failing the whole import.
PUT {uploadUrl}Content-Type: application/x-ndjson{"sku":"SKU-001","title":"First Product","priceMinor":1999,"currency":"USD","availability":"in_stock"}{"sku":"SKU-002","title":"Second Product","priceMinor":4999,"currency":"USD","availability":"in_stock"}The upload URL is a pre-signed direct-to-storage URL: do not add authentication headers to this request.
Step 3: Commit
POST /api/v1/projects/{projectId}/catalog/products/bulk/{ingestId}/commitNo request body is needed. A vs B streams the uploaded NDJSON, normalises each product through the freshest-wins merge, and writes the results to the Live Catalog.
curl -X POST https://app.avsb.cloud/api/v1/projects/42/catalog/products/bulk/ingest_abc123/commit \ -H "Authorization: Bearer avsb_svc_your_service_token"const projectId = 42const ingestId = 'ingest_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()Response (202):
{ "data": { "ingestId": "ingest_abc123", "accepted": true }}The commit response means the ingest job has been accepted, not that all products have been written. Processing continues in the background. Progress and any errors appear on the Product catalog page under the Push API source row.
If the ingestion worker can't be reached, or answers with an error, the commit itself fails with a 502 you can safely retry:
{ "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" }}accepted: true means the job was queued, not that every product is live yet. For very large catalogs, processing can take a few minutes. Check the Product catalog page to track progress.
Scope: catalog:write for all three staged-flow requests (the NDJSON PUT itself needs no scope, since the pre-signed URL is its own credential).
Priority in the freshest-wins merge
Push API writes are tagged source push_api. In the merge order:
live_event > shopify > push_api ≈ feed > uploadA push write wins over an uploaded dataset baseline, but is overridden by a Shopify webhook update or a real-time browser product view that arrived more recently.