Rate Limiting
The limit
| Limit | 200 requests per 60 seconds |
| Counted per | api_key (your supplier credential) |
| Scope | All endpoints, all methods, one shared budget |
| Window | Rolling 60 seconds |
| Response when exceeded | 403 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 return429 Too Many Requests. - No
Retry-Afterheader. 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
secreton a recognisedapi_keyproduces the exact same status and body. (An unrecognisedapi_keyis different — that returns500.) 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:
GET /partner/catalog/manifest— metadata plus a presigned URL to a gzipped NDJSON manifest. Download it and keep it.GET /partner/catalog/delta?since={snapshot_id}— the SKUs added, removed or changed since that snapshot.- Re-price
added+changedviaPOST /partner/lowest/bulk, dropremoved, and store the response'snext_sincefor the next poll. - 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:
| Endpoint | Cache-Control |
|---|---|
GET /partner/lowest/{sku} | public, max-age=300, s-maxage=600, stale-while-revalidate=120 |
GET /partner/settled/orders | public, 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/orders | public, 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/manifest | private, max-age=300 |
GET /partner/catalog/delta | public, max-age=60, s-maxage=60 |
POST /partner/lowest/bulk | not cached (POST) |
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/ordersandsettled/orders, 500 forlive/listings/{type}. There is nolimitparameter — sending one has no effect. - Follow
nextCursoruntil it isnull; 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.