StockroomAPIOpen the app
Guide

Conventions

These hold on every endpoint. Learn them once and the reference becomes a list of paths.

Shapes

  • JSON in and out. snake_case field names.
  • Ids are integers. Stockroom's own records carry Stockroom ids; products, variants, locations and inventory items carry the Shopify ids, the same numbers you see in the Shopify admin.
  • Timestamps are ISO 8601 in UTC with a Z: 2026-09-12T10:30:00Z. Plain dates are YYYY-MM-DD.
  • Money is a decimal string beside a currency code, never a float. Unit costs carry up to eight decimal places, because a cost per unit is a rate, not an amount of money.
  • Currencies are never blended. A purchase order is in its supplier's currency and its totals stay there. Nothing in the API sums across currencies, and you should not either.
  • A field with no value is null and is still present. Absent means "not part of this object".

Paging

Lists are cursor-paged and wrapped in the same envelope:

GET /v2/purchase_orders?limit=50

{
  "data": [ ... ],
  "next_cursor": "eyJpZCI6MTAyNH0",
  "has_more": true
}

limit is 1 to 250, default 50. To get the next page, pass the previous page'snext_cursor back as ?cursor=. When has_more isfalse, next_cursor is null and you are done.

Treat a cursor as opaque. It encodes where the walk stopped, and a cursor is only meaningful with the same filters you used to get it.

Filtering, and how to sync

Every list accepts updated_at_min, updated_at_max,created_at_min and created_at_max, each an ISO 8601 timestamp. Lists also take their own filters - status, supplier, location - which the reference gives per endpoint.

updated_at_min is the change feed. Store the highestupdated_at you have seen, ask for everything since, and page through. That catches edits, not just new records, which polling by created_at or by id does not.

Overlap your window by a minute or two rather than using the exact last timestamp, and make your own side idempotent on the record id. Two things make that worth doing: a record'supdated_at is the moment it changed rather than the moment you could read it, and a receipt's Shopify sync settles after the response (see Stock changes and sync state), which touches the record again.

If you would rather not poll at all, that is what webhooks are for. The honest pairing is both: webhooks for promptness, a nightly updated_at_min sweep to catch anything a failed delivery missed.

Errors

Every non-2xx response uses one envelope. code is the stable part - branch on it, never on the message.

{
  "error": {
    "code": "validation_failed",
    "message": "The request could not be processed.",
    "details": [
      { "field": "lines.2.quantity", "code": "min", "message": "The quantity must be at least 1." }
    ]
  }
}

details appears on validation failures and lists one entry per problem, with the request field that caused it. Messages are English and are written for you, not for the merchant - they are not translated, and they can be reworded. The codes cannot.

StatusCodeWhen
400bad_requestMalformed request.
400idempotency_key_requiredA write that moves stock, sent without an Idempotency-Key.
401unauthorizedBad, missing, revoked or expired key.
402feature_not_enabledThe store does not have API access.
403insufficient_scopeThe key lacks the scope the route needs.
404not_foundNo such record in this store.
409idempotency_key_reusedThat key was already used with a different body.
409idempotency_in_progressA request with that key is still running. Retry in a moment.
422validation_failedThe fields are wrong. details says how.
422invalid_transitionThe record cannot do that from the state it is in - closing a draft, receiving against an order that was never placed. The message says what state it is in.
422limit_reachedA per-store cap, like the webhook endpoint limit.
429rate_limitedOver a limit. Retry-After says when.
500internal_errorOurs. It is reported to us automatically. Retry is safe on a write that carried an idempotency key.

Idempotency

Every write accepts an Idempotency-Key header: any unique string per logical request, up to 255 characters. A UUID per attempt-group is the usual choice.

POST /v2/purchase_orders/1024/receipts
Idempotency-Key: 2f9c1a44-9f1e-4d0f-9f21-5a6f0d9c2f11
  • The first request with a key runs and its response is stored for 24 hours.
  • A replay with the same key and the same body returns that stored response, withIdempotent-Replayed: true. Nothing runs twice.
  • The same key with a different body is 409 idempotency_key_reused.
  • A replay while the first is still running is 409 idempotency_in_progress.
  • A 5xx or a 429 does not claim the key, so the same key is the right thing to retry with.

Two writes require the header and refuse without it: recording a receipt, and applying a stock adjustment. Those are the two that move stock, and a retried network call that double-receives a delivery is the kind of bug a store finds weeks later in a stocktake.

Rate limits

LimitPer minute
Requests, per key120
Requests, per store across all keys300
Writes, per store60

Over a limit is 429 rate_limited with Retry-After in seconds. Back off for that long rather than retrying immediately.

The write limit is the one a bulk job meets, and it is deliberately the tightest. Every write that moves stock also has to reach Shopify, whose own limits are the real ceiling: a script that receives 500 orders in a minute is 500 inventory pushes, and the store's own work in the admin queues behind them. Spread bulk work out.