Skip to content

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.

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 matching crm-api/<resource>.read scope.
  • 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.

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.

M2M clients can be granted these scopes — this is the complete grantable set:

ScopeGrants
crm-api/products.readRead products (list, get, search)
crm-api/products.writeWrite products (e.g. catalog sync)
crm-api/brands.readRead brands
crm-api/brands.writeWrite 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.

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).

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.

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.

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/overview
  • POST /products/existing-for-preview
  • POST /products/batch-get
  • POST /products/check-duplicates

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).