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

Plain text
Authorization: Bearer avsb_svc_your_service_token
Plain text1 line

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

Service tokens are secrets

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.

Rate limits and Idempotency-Key

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:

FieldTypeNotes
skustring (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.
titlestringProduct display name. Max 1,000 characters.
descriptionstringFull product description. Max 10,000 characters. Used by the Similar products algorithm.
urlstringCanonical product page URL. Max 2,048 characters.
imagestringMain product image URL. Max 2,048 characters.
brandstringManufacturer or brand name. Max 500 characters.
categorystringPrimary product type or category. Max 500 characters.
categoriesstring[]Additional categories (up to 50).
pricenumberPrice in major units (e.g. 89.00 for $89.00). A vs B converts to minor units using your project currency.
priceMinorintegerPrice in minor units (e.g. 8900 for $89.00). Wins over price when both are present.
compareAtPricenumberOriginal/was price in major units (for sale display).
compareAtPriceMinorintegerOriginal/was price in minor units. Wins over compareAtPrice when both are present.
currencystringISO 4217 currency code (e.g. "USD"). Defaults to your project currency.
pricesobjectPer-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.
availabilitystringOne of: in_stock, out_of_stock, preorder, removed. Defaults to in_stock if you don't send it.
stockintegerTotal inventory quantity (non-negative).
variantsarrayVariant list: see below. Up to 200 variants per product.
customFieldsobjectArbitrary key-value pairs. Up to 40 keys. Values can be string, number, or boolean.
createdAtintegerProduct creation timestamp in milliseconds since epoch. Used to power "new arrivals" sorting in recommendations.

Variant fields

Each object in the variants array can include:

FieldTypeNotes
variantSkustring (required)Unique identifier for this variant. Max 512 characters.
titlestringVariant display name (e.g. "Blue / Large"). Max 1,000 characters.
optionsobjectOption name to value map (e.g. {"color": "Blue", "size": "L"}).
pricenumberVariant price in major units.
priceMinorintegerVariant price in minor units. Wins over price.
stockintegerVariant-level inventory quantity.
availabilitystringVariant-level availability. Defaults to in_stock if you don't send it.
imagestringVariant-specific image URL. Max 2,048 characters.
Minor units and currency exponents

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.

Plain text
POST /api/v1/projects/{projectId}/catalog/products
Plain text1 line

sku 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      }    ]  }'
Shell20 lines

Response (201):

Response
{  "data": {    "upserted": 1,    "failed": 0,    "errors": []  }}
JSON7 lines

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:

400, more than 1000 products
{  "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"  }}
JSON19 lines

Scope: catalog:write. Rate limit: 120 requests per minute per token, shared with every other write route on this page.

Single product upsert

Plain text
PUT /api/v1/projects/{projectId}/catalog/products/{sku}
Plain text1 line

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"}'
Shell4 lines

Response:

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"    }  }}
JSON13 lines

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

Plain text
DELETE /api/v1/projects/{projectId}/catalog/products/{sku}
Plain text1 line

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"
Shell2 lines

Response:

Response
{  "data": {    "sku": "MERINO-NAVY-M",    "deleted": true  }}
JSON6 lines

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

Plain text
POST /api/v1/projects/{projectId}/catalog/products/bulk
Plain text1 line

Request body (optional):

JSON
{ "count": 45000 }
JSON1 line

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 }'
Shell4 lines

Response (202):

Response
{  "data": {    "ingestId": "ingest_abc123",    "uploadUrl": "https://r2.example.com/..."  }}
JSON6 lines

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.

Plain text
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"}
Plain text5 lines

The upload URL is a pre-signed direct-to-storage URL: do not add authentication headers to this request.

Step 3: Commit

Plain text
POST /api/v1/projects/{projectId}/catalog/products/bulk/{ingestId}/commit
Plain text1 line

No 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"
Shell2 lines

Response (202):

Response
{  "data": {    "ingestId": "ingest_abc123",    "accepted": true  }}
JSON6 lines

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:

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
Commit triggers background processing

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:

Plain text
live_event > shopify > push_api ≈ feed > upload
Plain text1 line

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

Was this helpful?