Limits, errors and retries
Each plan's per-minute and per-day ceilings, how usage is metered, the shape of errors, what to retry, and how to avoid duplicates with idempotency keys.
On this page
The ceilings
| Plan | Per minute per key | Per day per key | Keys |
|---|---|---|---|
| Free | — | 0 (no API) | 0 |
| Pro | 120 | 2,000 | 3 |
| Max | 120 | 20,000 | 10 |
| Corporate | 120 | No ceiling | No ceiling |
| Trial and sandbox | 120 | 200 | 1 |
- Ceilings are per key: two integrations with different keys never get in each other's way.
- The per-minute window is the last 60 seconds; the daily one, the last 24 hours.
- Past a ceiling you get
429witherror_code: "rate_limited"and the headerRetry-After: 60. Wait that many seconds before retrying. - The 120 per minute fit an integration's real work (a nightly push and polling its status). If you brush against them there is almost always a loop: filter on the server (
?signal=PEDIR_YA) instead of fetching everything and discarding.
How usage is metered
Every call that passes all checks adds one to that key's day (UTC). Refused calls — invalid key, route not exposed, read key writing, plan without the API, ceiling reached — do not count. An administrator sees the month's usage by day and by key on the API screen; revoked keys still appear with what they used.
The shape of an error
Errors carry no data. They carry detail (English text for people, whose wording can change) and a stable error_code with its error_params. Always decide on error_code or the HTTP status.
HTTP/1.1 422 Unprocessable Entity
{"detail":[{"type":"less_than_equal","loc":["query","limit"],
"msg":"Input should be less than or equal to 500","input":"9999","ctx":{"le":500}}],
"error_code":"validation_error"}In validation errors (422 validation_error), detail is a list: each item says in loc which field failed and in msg why.
| HTTP | error_code | What happened | What to do |
|---|---|---|---|
| 401 | unauthenticated | No credential was sent. | Add Authorization: Bearer sk_live_.... |
| 401 | api_key_invalid | Unknown, revoked or expired key. | Do not retry: change the key. |
| 403 | plan_feature_locked | The account's plan does not include the API (or MCP). | Do not retry: it needs another plan. |
| 403 | api_key_route_not_exposed | That route cannot be used with a key. | Do it in the app. |
| 403 | api_key_scope_insufficient | Read key on a route that writes. | Use a write key. |
| 404 | not_found | No such route, or a resource that does not exist in your account. | Check the route and the id. |
| 405 | method_not_allowed | Wrong method for that route. | Check GET/POST/PUT/PATCH/DELETE. |
| 409 | e.g. session_not_trainable, po_idempotency_key_reused | The state does not allow the operation, or an idempotency key was reused with other content. | Do not retry as is: read error_params. |
| 422 | validation_error or another code | The body or parameters are not valid. | Fix the request. |
| 429 | rate_limited | Per-minute or per-day ceiling. | Wait Retry-After seconds. |
| 500 | internal_error | Unexpected server failure. | Retry with growing waits. |
| 503 | server_busy | The server is saturated. | Retry with growing waits. |
What to retry
- Yes:
429(afterRetry-After),500,503and network drops. - No: the other
4xx. They will not change by waiting; retrying them only spends your quota. - A read can always be retried.
- A write is only safe to retry if the route is idempotent (below). If it is not and the connection dropped, check first whether it was applied.
import time, uuid, requests
def call(method, url, key, *, json=None, idempotency_key=None, tries=5):
headers = {"Authorization": f"Bearer {key}"}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key # same value on every retry
for attempt in range(tries):
r = requests.request(method, url, headers=headers, json=json, timeout=60)
if r.status_code == 429: # rate limited
time.sleep(int(r.headers.get("Retry-After", "60")))
continue
if r.status_code >= 500: # internal_error, server_busy
time.sleep(2 ** attempt)
continue
return r
r.raise_for_status()
# One order = one idempotency key, generated ONCE and reused on every retry.
key_for_this_order = str(uuid.uuid4())Idempotency: retrying without duplicating
A purchase order logged twice is stock "on its way" twice and a duplicate purchase. So the writes that create something accept a key that makes them safe to repeat:
| Route | How | On repeat |
|---|---|---|
POST /inventory/log-po | Idempotency-Key header (one UUID per order) | 200 with replayed: true and the original order. Same key with other lines: 409 po_idempotency_key_reused. |
POST /inventory/po | Idempotency-Key header | Same as log-po. |
POST /inventory-sync/profiles, .../refresh, .../profiles/{id}/run, .../profiles/apply | Idempotency-Key header (up to 128 characters) | Returns the first result; nothing is created or run twice. |
POST /inventory-sync/profiles/{id}/ingest | The batch's batch_id field | replayed: true and the same run. Same batch_id with other rows: 409 inventory_sync_batch_id_reused. |
POST /purchase-orders/{id}/erp-reference | Idempotent per (order, reference) | changed: false. Another reference for the same document: 409 erp_reference_conflict. |
POST /inventory/po/import | Each order in the file carries its own key | Importing the same file again does not duplicate orders. |
curl -X POST "https://app.stockai.es/api/v1/inventory/log-po?session_id=$SESSION" \
-H "Authorization: Bearer $STOCKAI_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2a0e-1b7d-4c55-9a51-3f0f4e0b8d21" \
-d '{"items":[{"sku":"SKU-001","recommended_qty":120,"final_qty":100,
"status":"modified","unit_cost":2.5,"supplier":"Distribuidora Andina"}]}'
# First call: 201, "replayed": false -> the order was created
# Same call again (same key, same lines): 200, "replayed": true -> the FIRST order, nothing new
# Same key, different lines: 409 po_idempotency_key_reused