Skip to main content

Rate Limiting

The limit​

Limit200 requests per 60 seconds
Counted perapi_key (your supplier credential)
ScopeAll endpoints, all methods, one shared budget
WindowRolling 60 seconds
Response when exceeded403 Forbidden

Every request counts — reads, writes, and requests that end in a 400. The budget is not per endpoint.

What a throttled response looks like​

HTTP/1.1 403 Forbidden
Content-Type: application/json

{"message":"Forbidden"}

That is all. Specifically:

  • Not 429. The API does not return 429 Too Many Requests.
  • No Retry-After header. There is no header telling you how long to wait, and no rate-limit headers of any kind (X-RateLimit-* do not exist).
  • Indistinguishable from a wrong secret. A wrong secret on a recognised api_key produces the exact same status and body. (An unrecognised api_key is different — that returns 500.) See Authentication.

Do not write code that keys off 429 or reads Retry-After — neither will ever appear, so such a handler silently never runs.

Handling it​

Because a throttle looks identical to a wrong secret, the right response to an unexpected 403 is: check your own send rate before you suspect your credentials — particularly if they were working a minute ago.

When you believe a 403 is a throttle, back off for a full 60 seconds, then resume at a lower rate. Retrying tightly makes it worse: each retry consumes budget from the same rolling window and extends the throttle.

const RATE_LIMIT_BACKOFF_MS = 60_000;

async function sasomRequest(path, options = {}, attempt = 0) {
const response = await fetch(`https://partners.sasomapi.com${path}`, {
...options,
headers: { ...options.headers, Authorization: authorization },
});

// 403 is ambiguous: it can be a throttle OR a wrong secret. Back off once;
// if it persists, stop and alert rather than hammering the API. Note a bad
// api_key surfaces as 500, not 403, so do not blind-retry 500s either.
if (response.status === 403 && attempt < 1) {
await new Promise((r) => setTimeout(r, RATE_LIMIT_BACKOFF_MS));
return sasomRequest(path, options, attempt + 1);
}

if (response.status === 403) {
throw new Error(
'Persistent 403 from SASOM Partners API: either sustained rate limiting ' +
'or an invalid credential. Check send rate, then verify credentials.',
);
}

return response;
}

A single-retry-then-alert policy is deliberate. An unbounded retry loop against an ambiguous 403 will spin forever on a revoked credential.

Designing to stay under the limit​

200 requests/minute is low on purpose. It is enough for order operations and for a catalog kept in sync properly; it is not enough to crawl SKU by SKU. Build for the limit rather than around it.

Price 100 SKUs in one request​

POST /partner/lowest/bulk accepts up to 100 SKUs per call and each result is byte-identical to what GET /partner/lowest/{sku} would return for that SKU. One bulk call costs one request instead of 100.

curl -sS -X POST https://partners.sasomapi.com/partner/lowest/bulk \
-u "$SASOM_API_KEY:$SASOM_API_SECRET" \
-H 'Content-Type: application/json' \
-d '{"skus":["FZ5246-100","DZ5485-612","AT8086-002"]}'

Read the deferred array in the response before retrying: those SKUs were not resolved because the request hit an internal work budget, and hot-looping them mostly returns the same set. Spread them over your normal schedule. deferred is not the same as not_found.

Mirror the catalog instead of polling it​

For keeping a local copy of prices, use the snapshot + delta loop rather than re-pricing everything:

  1. GET /partner/catalog/manifest — metadata plus a presigned URL to a gzipped NDJSON manifest. Download it and keep it.
  2. GET /partner/catalog/delta?since={snapshot_id} — the SKUs added, removed or changed since that snapshot.
  3. Re-price added + changed via POST /partner/lowest/bulk, drop removed, and store the response's next_since for the next poll.
  4. Re-download the manifest weekly to re-baseline.

Read the caveats on the manifest endpoint in the API Reference before relying on this — in particular, SKUs the manifest marks price_source: "live" are never reported as changed, and you own their refresh cadence.

Honour the cache headers​

GET responses carry a real Cache-Control. Respecting it is the cheapest way to cut your request count. These are the actual values, not suggestions:

EndpointCache-Control
GET /partner/lowest/{sku}public, max-age=300, s-maxage=600, stale-while-revalidate=120
GET /partner/settled/orderspublic, max-age=600, s-maxage=1200, stale-while-revalidate=180
GET /partner/live/listings/{type}public, max-age=60, s-maxage=120, stale-while-revalidate=30
GET /partner/processing/orderspublic, max-age=60, s-maxage=120, stale-while-revalidate=30
GET /partner/{order_id}public, max-age=30, s-maxage=60, stale-while-revalidate=15
GET /partner/payout/{order_id}public, max-age=30, s-maxage=60, stale-while-revalidate=15
GET /partner/catalog/manifestprivate, max-age=300
GET /partner/catalog/deltapublic, max-age=60, s-maxage=60
POST /partner/lowest/bulknot cached (POST)
Do not put the catalog manifest in a shared cache

GET /partner/catalog/manifest is marked private deliberately: its body contains a presigned URL, which is itself a credential for the catalog object. Storing that response in a shared or corporate proxy cache would let anyone using that proxy replay the credential. Cache it per-process at most.

Compress and paginate sensibly​

  • Send Accept-Encoding: gzip. Successful responses are gzipped, and the list endpoints are large.
  • Page size is fixed by the server: 100 for processing/orders and settled/orders, 500 for live/listings/{type}. There is no limit parameter — sending one has no effect.
  • Follow nextCursor until it is null; do not fetch pages in parallel, and put a short delay between pages so a deep walk does not exhaust the window.
async function fetchAllProcessingOrders() {
const all = [];
let cursor = null;

do {
const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : '';
const response = await sasomRequest(`/partner/processing/orders${query}`);
const page = await response.json();

all.push(...page.orders);
cursor = page.nextCursor;

if (cursor) await new Promise((r) => setTimeout(r, 250));
} while (cursor);

return all;
}

Note the field names: the array is orders (not data) and the cursor is nextCursor (not cursor). There is no has_more field — nextCursor: null is the end-of-pages signal.

Need a higher limit?​

Contact your SASOM account manager with your api_key's integration name (not the key or secret), your measured request rate, and what is driving it. Expect to be asked first whether you are using the bulk and catalog endpoints — in most cases the limit is not the constraint, the crawl pattern is.