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
  1. The ceilings
  2. How usage is metered
  3. The shape of an error
  4. What to retry
  5. Idempotency: retrying without duplicating

The ceilings

PlanPer minute per keyPer day per keyKeys
Free—0 (no API)0
Pro1202,0003
Max12020,00010
Corporate120No ceilingNo ceiling
Trial and sandbox1202001
  • 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 429 with error_code: "rate_limited" and the header Retry-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.

HTTPerror_codeWhat happenedWhat to do
401unauthenticatedNo credential was sent.Add Authorization: Bearer sk_live_....
401api_key_invalidUnknown, revoked or expired key.Do not retry: change the key.
403plan_feature_lockedThe account's plan does not include the API (or MCP).Do not retry: it needs another plan.
403api_key_route_not_exposedThat route cannot be used with a key.Do it in the app.
403api_key_scope_insufficientRead key on a route that writes.Use a write key.
404not_foundNo such route, or a resource that does not exist in your account.Check the route and the id.
405method_not_allowedWrong method for that route.Check GET/POST/PUT/PATCH/DELETE.
409e.g. session_not_trainable, po_idempotency_key_reusedThe state does not allow the operation, or an idempotency key was reused with other content.Do not retry as is: read error_params.
422validation_error or another codeThe body or parameters are not valid.Fix the request.
429rate_limitedPer-minute or per-day ceiling.Wait Retry-After seconds.
500internal_errorUnexpected server failure.Retry with growing waits.
503server_busyThe server is saturated.Retry with growing waits.

What to retry

  • Yes: 429 (after Retry-After), 500, 503 and 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:

RouteHowOn repeat
POST /inventory/log-poIdempotency-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/poIdempotency-Key headerSame as log-po.
POST /inventory-sync/profiles, .../refresh, .../profiles/{id}/run, .../profiles/applyIdempotency-Key header (up to 128 characters)Returns the first result; nothing is created or run twice.
POST /inventory-sync/profiles/{id}/ingestThe batch's batch_id fieldreplayed: true and the same run. Same batch_id with other rows: 409 inventory_sync_batch_id_reused.
POST /purchase-orders/{id}/erp-referenceIdempotent per (order, reference)changed: false. Another reference for the same document: 409 erp_reference_conflict.
POST /inventory/po/importEach order in the file carries its own keyImporting 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