Audience: integrators (game stores, marketplaces, backends) and reviewers auditing how the Seller API stays cheap at high request volume.
Example#
curl -s -X GET "https://api.avrix.io/api/seller/v1/catalog/snapshot" \
-H "Authorization: Bearer $AVRIX_API_KEY" \
-H "If-None-Match: "etag-from-prior-response""This document is the contract for read-side caching on the Seller API. It explains where caches live, when reads are free against your rate budget, and the anti-patterns that get your integration flagged as misbehaving.
For what to synchronise and in what order, see Catalog and allocations. This page covers only how to make those calls cheap.
TL;DR#
- All catalog reads carry a weak ETag. Resending it as
If-None-Matchreturns304 Not Modifiedwith zero JSON body and zero rate-limit cost. - An edge cache serves per-company responses for catalog routes
(
Vary: Authorization, Accept-Language). An edge hit never reaches the origin and never debits your rate bucket. - Subscribe to webhooks (
product.updated,allocation.updated,order.fulfilled). Webhooks reduce tight polling; keep scheduled reconciliation / catalog delta polls (withIf-None-Match/updatedSince) as the safety net. - The rate limiter has a separate, 10× more generous bucket
(
read-cached) for cacheable catalog routes. If you keep getting 429s on/productsyou are doing something wrong; see Forbidden anti-patterns.
Where caches live#
Three tiers, each independently invalidated:
| Tier | Scope | Lifetime | Who pays for a miss |
|---|---|---|---|
| Edge cache | per company (API key + locale) | s-maxage (60-300s) + stale-while-revalidate (300-600s) | nobody if If-None-Match matches; otherwise the API |
| Shared response cache | per seller company | 60-300s depending on route | an origin round-trip |
| Origin database | global | source of truth | the database |
The edge is the dominant cost reducer: at sustained traffic, ~80%+ of catalog reads should resolve there without ever reaching the API.
ETags#
Every catalog response includes an ETag header, e.g.
ETag: W/"a1b2c3d4..."
Send it back on the next request as If-None-Match:
GET /api/seller/v1/products HTTP/1.1
Authorization: Bearer …
If-None-Match: W/"a1b2c3d4..."
If the underlying data hasn't changed you get back 304 Not Modified:
- No JSON body.
- No Postgres query.
- Zero rate-limit consumption.
This makes per-minute polling against an unchanged catalog effectively free,
both in latency and rate budget. All polling clients must implement
If-None-Match.
The ETag is a stable hash of the response body for /products,
/products/:id, /products/:id/pricing, /availability, and /partners,
and is derived from (maxUpdatedAt, totalProducts) for
/catalog/snapshot so it stays stable across snapshot regenerations whose
underlying catalog hasn't changed.
Rate-limit fairness#
The Seller API splits the rate bucket on kind:
read— read budget (sandbox keys only; production keys are not rate limited).read-cached— 10× thereadcap, used for the cacheable catalog routes' pre-flight check.write— order writes, key returns, webhook config writes.
For cacheable routes, the pre-flight only debits read-cached. The full
read budget is debited only when the route did fresh origin work:
| Response | Debits read? |
|---|---|
304 Not Modified (matched If-None-Match) | no |
200 with X-Cache: HIT (served from the shared response cache) | no |
200 with X-Cache: MISS or BYPASS (origin work) | yes |
Inspect the X-Cache, X-RateLimit-Limit, and X-RateLimit-Remaining
headers on every response to track what your integration is paying for.
Keeping sync cheap#
The canonical sync algorithm — snapshot, allocations, /catalog/changes
deltas, and the sellability gates — lives on
Catalog and allocations. Follow it there; the
three techniques below are what make it inexpensive.
1. Always send If-None-Match#
Persist the ETag from every catalog response and resend it. Unchanged data
returns 304 with no body and no rate cost, so a five-minute poll against a
static catalog is effectively free.
2. Let webhooks drive the delta#
| Event | Use case |
|---|---|
product.updated | Trigger a delta sync immediately instead of polling on a timer |
allocation.updated | Refresh availability for the affected SKU |
order.fulfilled / order.preorder_fulfilled | Update order status without polling GET /orders/{id} |
product.delisted | Remove the product from your storefront within seconds |
Webhooks plus deltas plus infrequent If-None-Match / reconciliation polls is a near-zero rate cost.
Do not drop scheduled reconciliation entirely — webhooks reduce tight polling; they do not replace it.
3. (High-volume browse) bundle with ?expand=#
For storefront browse pages that show 100 products with stock + price at once, use:
GET /api/seller/v1/products?expand=availability,pricing&limit=100
This adds per-row availability and pricing summaries to the list
response, replacing the canonical N+1 pattern of one list call followed by
100 detail calls. The availability field is advisory — call
/availability for commit-time accuracy.
Forbidden anti-patterns#
These will get your integration flagged in the Integration Health panel and may trigger account-level rate caps.
1. Polling /products every 30 seconds#
If you're polling /products more than once per minute without
If-None-Match, you are paying full rate cost for nothing. Use webhooks
plus ?updatedSince= instead.
2. Crawling the full catalog repeatedly#
Once you have your local catalog, never paginate from the start again. Use
/catalog/snapshot once at boot, then deltas. Repeated full crawls are the
surest way to exhaust your rate budget.
3. Calling /products/:id 100× after /products#
That's the N+1 anti-pattern. Use ?expand=availability,pricing on the list
endpoint. If you also need full pricing tables (regional SRP, edition-level
pricing), call /products/:id/pricing only on user navigation, never as
part of the initial browse render.
4. Calling /availability per row on a list page#
Use ?expand=availability on /products for the browse view. Reserve
/availability for the cart / commit step where accuracy matters.
5. Ignoring Retry-After#
When a 429 comes back, the Retry-After header tells you exactly when to
retry. Burst-retrying instead of waiting wastes your remaining budget and
extends the back-off.
6. Stripping or rewriting Idempotency-Key between retries#
Order writes (POST /orders, POST /keys/pull) are idempotent per
Idempotency-Key. Reuse the same key on retries — never generate a new
one. See Integration recipes.
7. Disabling gzip#
/catalog/snapshot is large by design. Always send Accept-Encoding: gzip.
Cache headers reference#
| Route | Cache-Control | Vary |
|---|---|---|
| GET /products | private, max-age=90, s-maxage=90, stale-while-revalidate=300 | Authorization, Accept-Language |
GET /products/:id | private, max-age=90, s-maxage=90, stale-while-revalidate=300 | Authorization, Accept-Language |
GET /products/:id/pricing | private, max-age=300, s-maxage=300, stale-while-revalidate=300 | Authorization, Accept-Language |
| GET /availability | private, max-age=60, s-maxage=60, stale-while-revalidate=300 | Authorization, Accept-Language |
| GET /partners | private, max-age=120, s-maxage=120, stale-while-revalidate=300 | Authorization, Accept-Language |
| GET /allocations | private, max-age=60, s-maxage=60, stale-while-revalidate=300 (plus a shared body cache per page, ~60s TTL) | Authorization, Accept-Language |
| GET /catalog/changes | private, max-age=60, s-maxage=60, stale-while-revalidate=300 (per company) | Authorization, Accept-Language |
| GET /catalog/checksum | private, max-age=60, s-maxage=60, stale-while-revalidate=300 (per company) | Authorization, Accept-Language |
| GET /catalog/snapshot | private, max-age=300, s-maxage=300, stale-while-revalidate=600 | Authorization, Accept-Language, Accept-Encoding |
GET /allocations guidance: treat it as inventory-sensitive checkout authority. Published
cache TTL is about 60 seconds; it uses the orders rate-limit class (not the catalog
read-cached pre-flight). Sync on a schedule / webhook and serve your storefront from the
mirror — do not call it per product page.
GET /whoami is not edge-cached. It serves a light readiness summary
(~90s); pass ?diagnostics=1 only when you need full publisher and pricing
diagnostics.
To intentionally bypass all caches (debugging only), send:
Cache-Control: no-cache
See also#
- Catalog and allocations — the canonical sync algorithm these techniques make cheap
- Order preview — checkout pricing before order commit
- Webhooks — event types, signatures, delivery SLO
- Integration recipes — call order per flow
- Partner runbook — rate limits and support checklist