Auth model & scopes
This page explains the authentication model behind the API and what the M2M surface covers. For step-by-step token instructions, see the Authenticate how-to.
Two kinds of caller
Section titled “Two kinds of caller”The API distinguishes two callers, authenticated differently:
- M2M integrations use an OAuth 2.0 client-credentials access token
(a Bearer token from
/oauth2/token). Every catalog endpoint (Products, Brands) requires this token plus the matchingcrm-api/<resource>.readscope. - Administrators managing M2M clients (
/oauth/clients) authenticate with a Cognito ID token carrying Admin group membership. These endpoints are not callable with an M2M client-credentials token.
What’s available via M2M
Section titled “What’s available via M2M”The M2M API is read-oriented for catalog data. With client-credentials access your integration can read Products and Brands (list, get-by-id, and search).
The endpoints require the scopes crm-api/products.read and
crm-api/brands.read. A client is granted only the scopes it needs.
The Quotes and Templates workflows (creating and editing quotes, versions, templates, and bulk pricing) are part of the web application and are not exposed to the M2M API.
Scopes
Section titled “Scopes”M2M clients can be granted these scopes — this is the complete grantable set:
| Scope | Grants |
|---|---|
crm-api/products.read | Read products (list, get, search) |
crm-api/products.write | Write products (e.g. catalog sync) |
crm-api/brands.read | Read brands |
crm-api/brands.write | Write brands |
Scopes are requested at token issuance as a space-separated scope string and
granted per client by an administrator. Quote/template scopes, and client
(customer-record) scopes, are intentionally not part of the grantable set — an
attempt to grant them is rejected at grant time, not merely unusable once
granted.
Field policy
Section titled “Field policy”M2M read responses for Products and Brands are projected through a server-owned allowlist before they leave the API. This is separate from scopes: a scope controls whether you can call an endpoint at all, the field policy controls which fields come back in the response body. The caller cannot widen the allowlist — there is no parameter or scope that returns fields outside it.
Product fields available to M2M callers:
id, productCode, name, description, brandName, brandSlug,
range, category, department, subDepartment, stockType, catalogue,
displayOnWeb, notes, colour, price, retailPrice, supplierCost,
barcode, length, height, width, weight, weightUnit, createdAt,
imageUrl, images (an array of {url, name}), documents (an array
of {url, name}), erpCode, altSupplierCode, altSupplierCode2,
webHierarchy, type, subType, subSubType, and retailRange.
notes is labelled “Internal Notes” in the CRM UI, but is used as a
public-facing short description by M2M consumers, not private staff content.
supplierCost is wholesale/cost-basis price — treat it as commercially
sensitive. erpCode, altSupplierCode, altSupplierCode2,
webHierarchy, type, subType, subSubType, and retailRange support
an ERP catalogue-sync integration — identifiers/classification, no cost
data.
Also available: a projected relatedProducts array —
{productId, productCode?, erpCode?, relationType?, qty?, source?}. See
M2M Product Sync for the exact shape and the
GET /products/{id} vs GET /products/search inline-field asymmetry.
Brand fields available to M2M callers:
id, name, slug.
Any field not in the lists above is never returned to an M2M caller. On
relatedProducts entries specifically, note, name, imageUrl, and
archived are never returned. updatedAt is
also not on the allowlist and will not appear in the response body, even
though it remains usable as a sort value and via the updatedAfter query
parameter (see below).
fields= is narrowing-only
Section titled “fields= is narrowing-only”For an M2M caller, the fields= query parameter can only narrow the response
within the allowlist above — it can never widen it. Requesting a field outside
the allowlist (e.g. fields=brandId) does not error; the denied field is
silently omitted from the response.
Sort fields are restricted
Section titled “Sort fields are restricted”M2M sorting accepts a fixed set of sort column IDs: name, code,
brand, price, category, department, subDepartment, supplierCost,
createdAt, updatedAt. These are column IDs, not response field names —
code sorts by productCode, brand by brandName. Every accepted column
maps to a field on the external allowlist (plus updatedAt); most allowlist
fields are not sortable. Any other value returns 400 with
error: "INVALID_SORT_FIELD" and a message listing the allowed values.
This restriction exists because the sort field and value are encoded into the
syncToken pagination cursor — an unrestricted sort would leak exact cost
values through the cursor even though the response body itself is clean. See
Pagination for how syncToken works.
Denied internal routes
Section titled “Denied internal routes”A small set of routes are on an exact-path M2M denylist and return 403 to
every M2M token regardless of granted scope, because they are internal
operational surfaces rather than part of the public catalog API:
GET /brands/overviewPOST /products/existing-for-previewPOST /products/batch-getPOST /products/check-duplicates
Revocation
Section titled “Revocation”Revoking a client prevents it from obtaining new access tokens — revocation takes effect immediately for new token issuance. Existing, already-issued access tokens are rejected from the next API request (if the revocation check is temporarily unavailable, they are accepted until they expire).