Tenancy and access
Collaterate is multi-tenant. A site is the unit of tenancy, and your partner record carries a set of granted site ids. Everything this API returns is filtered to that set.
Your grant
Section titled “Your grant”Grants are configured by Collaterate, per environment, and are not something you can inspect
indirectly or extend yourself. The authoritative view of your own grant is GET /v1/me:
{ "partnerId": "1", "partnerName": "Acme", "grantedSiteIds": [7, 8], "authMethod": "COGNITO_M2M", "scopes": ["partner-api/orders:read"]}grantedSiteIds is never empty for a working credential. A partner with no granted sites
cannot authenticate at all - see
when authentication fails.
Two distinct things control what you may do, and it is worth keeping them apart when debugging:
| Concept | Question it answers | Where you see it | Failure looks like |
|---|---|---|---|
| Grant | Which sites’ data? | grantedSiteIds |
404 on an order, or 400 invalid_site_id on a filter |
| Scope | Which operations? | scopes |
403 insufficient_scope |
How scoping is enforced
Section titled “How scoping is enforced”Site scoping is applied when the query is built, on every read, unconditionally. There is no code path in this API that can construct a query without it, and no parameter you can send that relaxes it. A row belonging to a site outside your grant is not filtered out of a response - it never leaves the database in the first place.
Two consequences follow, and both are visible to you:
Filtering by siteId narrows, it never widens. If you pass a siteId that is in your
grant, you get that site’s orders. If you pass one that is not, you get a 400 invalid_site_id. The parameter is never silently ignored, which would quietly widen the
result back to all of your sites and give you a different answer than the one you asked for,
and it never returns an empty page, which would be indistinguishable from a legitimate site
with no orders.
Some orders are excluded from every response. Orders that Collaterate does not mark as live - test and internal orders - are never returned to partners, regardless of grant. This is not configurable and there is no parameter to include them.
On writes, this API is the only per-site guard
Section titled “On writes, this API is the only per-site guard”Reads are scoped by the query layer, which cannot be bypassed. Writes need something more, because the system this API writes to enforces access per supplier, not per site - and one supplier can span hundreds of sites. Several of its write paths will accept an id belonging to a site you were never granted.
So every id in every write body is checked against your grant before anything is sent: a product, a variant, an image, a segment, a division, a user. An id that is not yours is refused, and the refusal is identical whether the id belongs to another site or does not exist at all - the same 404-never-403 rule described below, applied to request bodies rather than to paths.
Two consequences worth knowing. A write that names one bad id is refused whole - nothing is partially applied, because skipping the bad id and proceeding is how the platform itself loses data. And these checks are ours: a change made outside this API is not subject to them.
404, never 403
Section titled “404, never 403”GET /v1/orders/{orderNumber} returns the same 404 order_not_found for an order number
that was never issued and for an order that exists in a site you are not granted.
This is deliberate and permanent. Because a row outside your grant never comes back from the
database, the service genuinely cannot distinguish the two cases - and it must not try, since
confirming that an order exists somewhere you cannot see is itself a disclosure. Returning
403 here would let anyone holding any credential enumerate valid order numbers across the
whole platform.
When a change to your access takes effect
Section titled “When a change to your access takes effect”On your next request. Your partner record, your grants and your capabilities are looked up fresh on every call rather than being baked into your credential.
This has a practical consequence in both directions:
- A new site added to your grant is visible immediately. You do not need a new token, and a token you already hold picks up the new site on its next use.
- Access removed is gone immediately, for the same reason. There is no window in which an already-minted token keeps working against a site you no longer have.
The one exception is mutual TLS offboarding: removing a certificate from the truststore takes several minutes to propagate through the edge, though the partner record change itself is immediate and the credential stops resolving right away.
Site ids
Section titled “Site ids”Site ids are integers, stable, and assigned by Collaterate. You get yours from
GET /v1/me, and every order carries its own in the siteId field so you can attribute a
result from an unfiltered list.
GET /v1/me needs no scope, but the bare ids in grantedSiteIds are all it gives you. If you
also need each site’s name and url, GET /v1/sites returns them for your granted sites - it
needs the partner-api/sites:read scope, listed with the others in
Authentication. GET /v1/sites/{siteId} fetches
one of them, once you already have the id from grantedSiteIds or from a resource’s own
siteId field.
There is no endpoint in v1 that lists sites beyond your own grant, and there is no way to
discover whether a given site id exists - GET /v1/sites lists only your own, same as
GET /v1/me. A site id you are not granted behaves identically to one that was never issued.
The site is the root of the tree
Section titled “The site is the root of the tree”/v1/sites is where every site-scoped collection in this API roots. GET /v1/sites/{siteId}
addresses one site, and GET /v1/sites/{siteId}/users, .../divisions, .../credit-accounts,
.../segments, .../products and .../orders each address that site’s slice of a collection
you can also reach flat, filtered by ?siteId=. Every one of those pairs is the same handler,
the same result, the same errors - walking the tree or querying the flat collection changes
nothing about the data or the access rules, only the shape of the URL.
What does change, deliberately, is the shape of the error when the site is not yours:
| Request | Status | code |
Why |
|---|---|---|---|
GET /v1/sites/999/users |
400 |
invalid_site_id |
A filter naming a site you do not hold is a malformed request - the same rule ?siteId=999 follows on the flat collection. |
GET /v1/sites/999 |
404 |
site_not_found |
A resource you do not hold is simply not there. Answering anything else would disclose that it exists somewhere you cannot see. |
Both are correct, and the difference is not an inconsistency to paper over. A nested collection
path takes {siteId} as a filter on a list - the same role ?siteId= plays on the flat
form - so a value outside your grant is refused the same way any bad filter is: 400.
GET /v1/sites/{siteId} alone takes it as the identity of the resource being fetched, so a
value outside your grant gets the same 404 every other by-id lookup gives a row you cannot
see - see 404, never 403 above.
What is recorded
Section titled “What is recorded”Successful reads are audited. The record holds who called, which endpoint, which sites were in the grant at the time, how many rows were returned, and the request id. It does not hold order contents - copying the data into a second store would double the exposure surface and buy nothing.
Requests that returned an error are not audited as accesses, because they read nothing.
Not scoped
Section titled “Not scoped”GET /v1/ping has no authentication and therefore no tenancy. It reports whether the service
is reachable and nothing about you or your data. It is the one endpoint here that behaves
identically for everyone.