Read this before you write catalog sync. The Avrix catalog model separates what you can see from what you can sell, and integrations that ignore the distinction ship storefronts that list products they cannot deliver.
Example#
curl -s -X GET "https://api.avrix.io/api/seller/v1/allocations" \
-H "Authorization: Bearer $AVRIX_API_KEY"Example#
curl -s -X GET "https://api.avrix.io/api/seller/v1/catalog/snapshot" \
-H "Authorization: Bearer $AVRIX_API_KEY"Who needs this#
Engineers building catalog sync, merchandising feeds, or checkout gates that must know which SKUs are actually purchasable.
TL;DR#
- Catalog feeds answer visibility;
GET /allocationsanswers capacity and sellability. - Merge both (and treat allocation-only SKUs as sellable even when they never appear in snapshot).
- Four gates must open before checkout: BMA → share → allocation → territory pricing.
- Echo preview’s
unitPriceCentsasexpectedUnitPriceCentson commit.
The core idea#
Two different questions have two different answers, from two different endpoints:
| Question | Endpoint | Answer |
|---|---|---|
| What products am I permitted to see? | GET /products, GET /catalog/snapshot | The shared catalog |
| What can I actually sell right now? | GET /allocations | Your capacity grants |
Neither is the sellable set on its own. Your sellable catalog is the intersection of the two, and each side contains entries the other does not.
The four gates#
In plain English: the vendor must (1) agree to trade with you, (2) share the specific SKU, (3) grant you units to sell, and (4) price the buyer’s country under the API model. Miss any one and checkout fails — even if the product looks great on a browse page.
A SKU becomes sellable through your storefront only when all four gates are open.
Gate 1 — Business Master Agreement. A BMA between you and the vendor establishes the commercial
terms for Seller API trading under the API business model — settlement basis and the scope of
what may be traded. Without an active BMA nothing that vendor owns is available to you at all.
Check hasActiveBma on GET /whoami. A missing or lapsed BMA produces CONTRACT_NOT_ACTIVE.
Gate 2 — SKU share. Within a BMA, the vendor chooses which specific SKUs to share with you and with what scope. A share with catalog scope makes the SKU appear in your catalog feeds. Sharing is about visibility and metadata rights, not stock.
Gate 3 — Allocation. An allocation grant gives you actual capacity — a number of units you may draw against. Without an allocation you cannot fulfil, regardless of visibility.
Gate 4 — Territory pricing. Under the API business model, a SKU is transactable in a given country only if a catalog list price exists for that country. Without one, orders fail with CATALOG_PRICE_NOT_SET even when the first three gates are open.
Why one endpoint is never enough#
The two feeds are gated differently, which produces two asymmetric blind spots.
| SKU situation | In /products and /catalog/snapshot? | In /allocations? | Sellable? |
|---|---|---|---|
| Shared and allocated | Yes | Yes | Yes |
| Shared, not allocated | Yes | No | No |
| Allocated, not shared | No | Yes | Yes |
| Neither | No | No | No |
Read the third row carefully. Allocation-only SKUs are sellable but never appear in the catalog
feeds. Vendors use this pattern for exclusive drops, bundle components, and inventory not
intended for public browsing. If you build your catalog from /products alone, these SKUs are
invisible to you and you lose the sales.
The second row is the opposite failure and the more common one: SKUs you can see, describe, and list — but cannot deliver. Listing them produces checkout failures after the buyer has committed.
The three product surfaces#
Product data is deliberately split across three surfaces rather than returned as one payload. Each serves a different access pattern, and using the wrong one is a performance problem rather than a correctness one.
| Surface | Endpoints | Use for |
|---|---|---|
| Browse and sync | GET /products, GET /catalog/snapshot, GET /catalog/changes, POST /catalog/exports | Building and maintaining your local catalog mirror |
| Rich metadata | GET /products/{id}, GET /products/{id}/pricing | Product detail pages — artwork, descriptions, ratings, regional prices |
| Checkout authority | GET /allocations, GET /availability, POST /orders/preview | Deciding whether and at what price to sell |
Do not call the checkout-authority endpoints to render a listing page, and do not rely on browse-and-sync data to decide whether to charge a buyer.
The catalog sync algorithm#
This is the one recommended algorithm. Implement it as written.
Step 1 — Cold start#
Pull the full shared catalog once.
curl -s "https://api.avrix.io/api/seller/v1/catalog/snapshot" \
-H "Authorization: Bearer avrix_sk_your_key_here"
For a large catalog, enqueue an immutable gzip NDJSON artifact instead of holding the
snapshot request open: POST /catalog/exports returns 202 { exportId, status: "pending" }.
Poll GET /catalog/exports/{exportId} (or wait for catalog.export_ready) for a short-lived
signed URL, checksum, and expiry. The live snapshot endpoint stays available.
{
"data": {
"products": [
{
"productId": "3f9a...",
"title": "Example Game",
"sharedSkus": [
{
"skuId": "8b9d...",
"skuCode": "EXAMPLE-WIN-US",
"platform": "windows",
"edition": "standard",
"srpCents": 5999,
"currencyCode": "USD"
}
]
}
],
"meta": {
"maxUpdatedAt": "2026-08-04T21:14:07.482Z",
"nextCursor": null,
"count": 218
}
}
}
Persist meta.maxUpdatedAt. It is your delta cursor. The snapshot returns your entire
visible catalog in one gzip-optimised response — there is no paging; meta.nextCursor is null.
Very large stores (tens of thousands of SKUs) can add ?format=ndjson to receive the same
snapshot as newline-delimited JSON: the first line is the meta object, then one product per
line, streamed so your importer can parse line-by-line in constant memory instead of loading one
giant document. ETag semantics are identical — send If-None-Match and you get 304 Not Modified when nothing changed, whichever format you use.
Step 2 — Fetch allocations#
curl -s "https://api.avrix.io/api/seller/v1/allocations?limit=500" \
-H "Authorization: Bearer avrix_sk_your_key_here"
{
"data": [
{
"allocationId": "f8b1...",
"skuId": "8b9d...",
"skuCode": "EXAMPLE-WIN-US",
"sellable": true,
"remaining": 942,
"pricePerKeyCents": 4199,
"currencyCode": "USD",
"readinessBlockers": []
},
{
"allocationId": "c410...",
"skuId": "77e2...",
"skuCode": "EXCLUSIVE-DROP-001",
"sellable": true,
"remaining": 500,
"pricePerKeyCents": 3999,
"currencyCode": "USD",
"readinessBlockers": []
}
]
}
EXCLUSIVE-DROP-001 did not appear in the snapshot. It is allocation-only, and it is sellable.
pricePerKeyCents is an advisory wholesale-cost summary when present on the grant.
readinessBlockers explains why anything with sellable: false is blocked. Under the API
business model, commit price authority is the territory catalog list price (see Pricing below).
After the first full fetch, poll allocations as deltas instead of refetching everything: each row
carries updatedAt, and GET /allocations?updatedSince=<your max updatedAt> returns only grants
mutated since then (resizes, revocations, status changes). Overlap the timestamp by a few seconds
between polls so nothing is missed. Allocations reads sit in the catalog rate class, so delta
polling never competes with your checkout budget. Sellability shown in catalog responses
(sharedSkus[].sellable) is computed from these same grant rows and grant changes invalidate the
cached catalog surfaces — allocations and catalog readiness cannot drift apart for longer than one
poll cycle.
Step 3 — Merge#
Build the sellable set by keying on skuId:
- In both — sellable, with full catalog metadata.
- Allocation only — sellable, but metadata must come from
GET /products/{id}if you want to merchandise it. Some vendors intend these to be sold only through a direct link. - Catalog only — not sellable. Keep it for reference if you want, but never expose a buy button.
Store sellable from the allocation, not from the catalog decoration.
Step 4 — Incremental updates#
Poll for deltas using the stored cursor (meta.maxUpdatedAt from snapshot, or the last applied
occurredAt from a previous changes page).
curl -s "https://api.avrix.io/api/seller/v1/catalog/changes?since=2026-08-04T21:14:07.482Z" \
-H "Authorization: Bearer avrix_sk_your_key_here"
{
"data": {
"changes": [
{ "productId": "9c22...", "type": "added", "occurredAt": "2026-08-05T07:01:11.204Z", "changedFields": ["availability", "metadata"] },
{ "productId": "3f9a...", "type": "updated", "occurredAt": "2026-08-05T07:44:02.119Z", "changedFields": ["pricing"] },
{ "productId": "1a04...", "type": "removed", "occurredAt": "2026-08-05T08:02:19.771Z", "changedFields": ["availability"] }
]
},
"meta": { "nextCursor": "eyJ2IjoxLCJzZXEiOjQyMTd9" }
}
Apply the latest change per product
The feed is a log, not a state. A product can carry several entries in one window — removed
followed by added when the content owner pauses and resumes an agreement, or removed + added
pairs when shares are re-scoped. Reduce the page to the newest entry per productId (by
occurredAt) before acting, and never delist a product that your latest GET /products or
snapshot still returns: a stale tombstone must not override a listing you have just received.
Cursor pagination (preferred)
Persist meta.nextCursor and pass it back as ?cursor= — it is an opaque, monotonic feed
position that is safe against timestamp ties and clock skew. nextCursor is null when the page
was not full. When both cursor and since are sent, the cursor wins.
since boundary (compatibility)
since remains supported and is exclusive: the server returns events with occurredAt
strictly after the timestamp you pass (occurred_at > since). After you apply a page,
advance to the last occurredAt — or better, switch to meta.nextCursor.
changedFields
Each entry lists the changed facets — pricing, availability, metadata, territory, or
relationships — so one changed price no longer forces a whole-product refetch. An empty
array means the facet is unknown (events recorded before facet tracking): refetch the product.
Tombstones (type: "removed") and retention
removed rows are tombstones — delist the product from your storefront immediately. Delta events
are retained for at least 30 days: a store offline for up to a month catches up via deltas
alone. If your cursor is older than retained history, or GET /catalog/checksum drifts from your
mirror, cold-start with GET /catalog/snapshot and rebuild — do not invent local replay from
stale state.
What the platform emits when a product leaves (or partially leaves) your view:
- Archive / full delist — the product stops being live:
removed. It also disappears fromGET /productsandGET /catalog/snapshot. - Unshare — when the revocation ends your catalog access to the product,
removed(and the product drops from list/snapshot). When your access comes from whole-catalog sharing and the product stays browse-visible,updatedwithchangedFields: ["availability"]— refetch: its SKUs stopped being sellable even though the listing remains. - Release- or SKU-level delist while the product stays live —
updatedwithchangedFields: ["availability"]: refetch the product and re-readGET /allocationssellablefor the affected SKUs.
Advance your cursor only after the batch has been applied successfully. If application fails partway, keep the old cursor and retry: replaying a page is safe because both paging modes are exclusive of already-applied positions.
Re-run step 2 on every sync. Allocations change independently of catalog metadata — capacity is consumed, grants are extended, and sellability flips without any catalog event.
Step 5 — Drift detection#
curl -s "https://api.avrix.io/api/seller/v1/catalog/checksum" \
-H "Authorization: Bearer avrix_sk_your_key_here"
The digest covers the set of visible product IDs for your seller (optionally filtered by
partner) together with each product’s updated_at, hashed as
sha256-sorted-product-rows-v1 (digest, totalProducts, maxUpdatedAt). Treat a checksum
change as a signal to cold-start — do not try to recompute the digest locally against a
partial mirror.
Compare on a schedule (daily is typical). Drift usually means a dropped delta batch or a cursor that fell outside retained history. Recover with a full cold start (step 1).
Step 6 — Event-driven refresh#
Subscribe to these events to refresh sooner than your poll interval:
| Event | Refresh |
|---|---|
product.updated | The affected product |
product.announced | Catalog delta |
product.delisted | Delist immediately |
release.available | Catalog delta |
release.date_changed | Listing dates; notify pre-order buyers |
sku.pricing_updated | Territory pricing for that SKU |
sku.sellability_changed | Buy button for that SKU (confirm with GET /allocations) |
allocation.updated | Allocations |
allocation.depleted | Allocations — stop selling that SKU |
allocation.low_stock | Allocations — consider throttling |
Webhooks supplement polling; they do not replace it. Delivery is at-least-once, not exactly-once, and an endpoint outage means missed events. Keep the scheduled poll running.
Allocation revocation semantics#
allocation.updated payloads carry a reason:
created, resized (ceiling change), rebalanced, approved, pulled, returned,
updated, paused, or revoked.
When a publisher revokes or pauses a grant mid-flight:
- Open checkout holds and hot-drop reservations for that grant are cancelled — an in-flight
POST /orders/commitfor a cancelled reservation returnsRESERVATION_GONE, and aPOST /orderswith a cancelledcheckoutHoldIdfails hold validation. allocation.updatedis delivered withreason: "revoked"(or"paused"); stop selling the SKU immediately and treatGET /allocationsas authoritative on the next sync.- Already-fulfilled orders are unaffected — revocation is never retroactive.
Cadence#
| Operation | Suggested frequency | Notes |
|---|---|---|
GET /catalog/snapshot | Cold start and after checksum drift only | Catalog rate class |
POST /catalog/exports | Large-catalog bootstrap when you want a resumable artifact | 202 { exportId }; poll GET /catalog/exports/{exportId} |
GET /catalog/changes | Every 5–15 minutes, plus on webhook | Page with limit; exclusive since |
GET /allocations | Every 5–15 minutes, plus on allocation webhooks | Orders rate class; published cache guidance ~60s |
GET /catalog/checksum | Daily | Integrity signal — cold-start on change |
GET /availability | At checkout, not during browsing | |
POST /orders/preview | When the buyer reaches checkout |
Honour ETag and Cache-Control on catalog reads; a conditional request that returns 304 does
not count against your rate budget. See the caching guide.
Pricing#
Seller API partnerships use the API business model. Your cost basis at commit is the territory catalog list price for the buyer's country (or the price snapshot bound by an active checkout hold).
Safe rule: echo the unitPriceCents returned by the most recent POST /orders/preview for the
same SKU, quantity, and country as expectedUnitPriceCents (the field alias
expectedWholesaleUnitPriceCents is accepted and must match if both are sent).
Price guards fail closed. Omitting expectedUnitPriceCents is rejected with
EXPECTED_UNIT_PRICE_REQUIRED — there is no default. A stale value is rejected with
CATALOG_PRICE_MISMATCH rather than being silently repriced. (A legacy
WHOLESALE_PRICE_MISMATCH code may still appear in the contract for non-API grants; it is not
used by current Seller API partnerships.) This is deliberate: you can never be charged a price you
did not see.
Your retail price is entirely yours. Nothing above constrains what you charge the buyer.
Why a SKU is not sellable#
Symptom → missing gate#
| What you see | Likely missing gate | What to check |
|---|---|---|
| Empty partners / no catalog at all | 1 — BMA | GET /whoami → hasActiveBma; ask your vendor / Avrix contact |
| SKU absent from snapshot and allocations | 2 — Share (or no BMA) | Vendor share scope; partners list |
SKU in catalog, sellable: false, blocker NO_ALLOCATION | 3 — Allocation | Capacity grant inactive or never issued |
SKU allocated, preview/order fails with CATALOG_PRICE_NOT_SET / territory errors | 4 — Territory pricing | Buyer countryCode / salesCountryCode vs catalog price |
Catalog shows “coming soon”, commit returns PRODUCT_NOT_SELLABLE | Lifecycle (not a commercial gate) | launchPhase must be available for commits |
readinessBlockers codes#
readinessBlockers on GET /allocations names the gate that is closed.
| Blocker | Gate | Resolution |
|---|---|---|
NO_SKU_SHARE | 2 | The vendor has not shared this SKU with commit authority |
NO_ALLOCATION | 3 | Shared but no capacity granted / grant inactive |
NO_TERRITORY_PRICING | 4 | No territory price for the country (API model) |
PRODUCT_NOT_SELLABLE | Lifecycle | Not transactable — launchPhase is not available, or discontinued/archived |
PRODUCT_EXCLUDED | 2 | Product excluded from your share |
Gates 1 through 3 are commercial and resolve through your vendor relationship, not through the API.
Launch phases#
Separate from the four gates, each product carries a lifecycle state that controls whether it can be listed and ordered. Catalog feeds use a broader visibility predicate; commits use a stricter sellability predicate.
launchPhase | Listable (catalog) | Orderable (transact) |
|---|---|---|
draft | No | No |
announced | Yes — as "coming soon" | No |
pre_order | Yes | No |
available | Yes | Yes |
discontinued | No | No |
A product is transactable only when launchPhase is available, tradingState is not
discontinued, and it is not archived. announced and pre_order stay discoverable in catalog
feeds but commits reject them with PRODUCT_NOT_SELLABLE. The same code appears in
readinessBlockers on GET /allocations when sellable is false for lifecycle reasons.
tradingState values are open, paused, and discontinued. Discontinued (and archived)
products are neither listable nor orderable.
Common mistakes#
Building the catalog from /products alone. Misses allocation-only SKUs and lists unsellable
ones. The most common and most costly defect.
Trusting the catalog sellable decoration. It is cached and can lag. Allocations are
authoritative.
Ignoring tombstones. removed entries must be delisted, not just left to expire.
Caching preview prices. A preview price is valid for the open cart, not for your database. Refresh at the final pay click.
Calling /allocations per product page. It is a checkout-authority endpoint (orders rate
class, ~60s cache TTL). Sync it on a schedule and read from your mirror — see
Caching and fairness.
Advancing the cursor before applying the batch. A crash mid-apply then silently skips the delta.
Assuming added / updated / removed top-level arrays. The wire shape is
data.changes[] with a type discriminant — match OpenAPI, not older draft examples.
Next steps#
- Sellable SKU readiness — readiness blockers in depth
- Order preview — the pre-charge fulfilability gate
- Order lifecycle — after the buyer picks a SKU
- Webhook guide — event-driven refresh
- Error reference — catalog and pricing errors