Skip to content

Troubleshooting

This guide covers common issues encountered when using the CRM M2M API and provides step-by-step solutions.

Before diving into specific errors, run through this quick checklist:

  1. Verify credentials

    • ✅ Client ID is exactly 26 characters
    • ✅ Client secret was copied correctly (no extra spaces/newlines)
    • ✅ Client status is “active” (not revoked)
  2. Check API configuration

    • ✅ Using correct Cognito domain
    • ✅ Using correct API base URL
    • ✅ All requests use HTTPS (not HTTP)
  3. Validate token

    • ✅ Token was obtained successfully
    • ✅ Token is within its expires_in lifetime
    • ✅ Token includes required scopes
    • ✅ Using Authorization: Bearer <token> header

Full Error:

{
"error": "invalid_client",
"error_description": "Client authentication failed"
}

HTTP Status: 400 Bad Request

Causes:

  1. Incorrect client_id or client_secret
  2. Client has been revoked
  3. Extra whitespace in credentials
  4. Using ID token instead of credentials

Solutions:

Terminal window
# Check your client ID (should be 26 characters)
echo -n "abc123xyz456def789ghi012" | wc -c
# Output: 26
# Test with cURL (check for extra spaces)
curl -X POST {token-endpoint} \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=${CLIENT_ID}" \
-d "client_secret=${CLIENT_SECRET}" \
-d "scope=crm-api/products.read" \
-v # Verbose mode shows request details

Log in to the CRM admin panel and verify:

  • Client exists in Settings → API Access
  • Status shows “Active” (not “Revoked”)
  • Created date matches your records

If credentials are lost or potentially compromised:

  1. Create a new M2M client with the same scopes
  2. Update your application with new credentials
  3. Test with new client
  4. Revoke old client once migration is complete

Full Error:

{
"error": "invalid_scope",
"error_description": "Invalid scope"
}

HTTP Status: 400 Bad Request

Causes:

  1. Requesting scope not granted to client
  2. Typo in scope name (case-sensitive)
  3. Wrong scope format (missing crm-api/ prefix)

Solutions:

Verify which scopes your client has:

  1. Log in to CRM admin panel
  2. Navigate to Settings → API Access
  3. Find your client
  4. Review “Scopes” column

Correct format:

crm-api/products.read crm-api/brands.read

Common mistakes:

❌ products.read brands.read (missing prefix)
❌ crm-api/products.read,crm-api/brands.read (comma instead of space)
❌ crm-api/Products.read (wrong case)
❌ crm-api/products.delete (invalid scope — there is no .delete)
❌ crm-api/clients.read (clients is not a grantable resource — see below)

You don’t need to request all granted scopes:

Terminal window
# Client has: products.read, brands.read
# Request only what you need:
curl ... -d "scope=crm-api/products.read" # ✅ Valid

Solution 4: Invalid Scope Error When Creating a Client

Section titled “Solution 4: Invalid Scope Error When Creating a Client”

If you’re getting invalid_scope (or a validation error from POST /oauth/clients) when trying to grant clients.read, clients.write, quotes.read, or quotes.write — these are not grantable scopes. clients and quotes are not M2M resources; the client-creation endpoint rejects those scope names outright rather than accepting and silently ignoring them. The complete grantable scope set is:

crm-api/products.read
crm-api/products.write
crm-api/brands.read
crm-api/brands.write

Request one of these instead. If your integration needs customer/quote data, that is not available through the M2M API — it lives in the web application only.


Full Error:

{
"message": "Unauthorized"
}

HTTP Status: 401 Unauthorized

Causes:

  1. Missing Authorization header
  2. Token expired (older than its expires_in lifetime)
  3. Malformed Authorization header
  4. Using wrong token type (ID token vs access token)

Solutions:

Correct format:

Authorization: Bearer eyJraWQiOiJ...

Common mistakes:

❌ Authorization: eyJraWQiOiJ... (missing "Bearer")
❌ Authorization: Bearer "eyJraWQiOiJ..." (extra quotes)
❌ Authorization: bearer eyJraWQiOiJ... (lowercase "bearer")
❌ authorization: Bearer eyJraWQiOiJ... (lowercase header name - some libs fix this)

Decode your JWT to check expiration:

Terminal window
# Extract payload (second part of JWT)
TOKEN="eyJraWQiOiJ...your-token...9zA"
echo "$TOKEN" | cut -d'.' -f2 | base64 -d | jq '.'

Look for exp claim:

{
"exp": 1732544400, // Unix timestamp
"iat": 1732540800,
...
}

Convert to readable date:

Terminal window
date -d @1732544400
# Mon Nov 25 15:00:00 UTC 2025

If expired, request a new token.

class TokenManager {
constructor() {
this.token = null;
this.expiresAt = null;
}
async getToken() {
// Refresh if expired or about to expire (5-minute buffer)
if (!this.token || Date.now() >= this.expiresAt - 300000) {
await this.refreshToken();
}
return this.token;
}
async refreshToken() {
const response = await fetch(/* token endpoint */);
const data = await response.json();
this.token = data.access_token;
this.expiresAt = Date.now() + (data.expires_in * 1000);
}
}

Full Error:

{
"message": "Forbidden"
}

HTTP Status: 403 Forbidden

Causes:

  1. Missing required scope for the endpoint
  2. Calling a write endpoint with an M2M token (writes are admin web-app only — see below)
  3. Calling a Quotes, Templates, or Clients endpoint with an M2M token
  4. Calling an endpoint that is explicitly denied to M2M tokens regardless of scope (see below)
  5. Client was recently revoked, and you’re still using its old credentials

Solutions:

The M2M API is read-only. These are the M2M-usable endpoints and their scopes:

EndpointRequired Scope
GET /products, /products/:id, /products/searchcrm-api/products.read
GET /brands, /brands/:id, /brands/searchcrm-api/brands.read
POST /oauth/clientsAdmin group (ID token), not an M2M token

Decode your token to verify scopes:

Terminal window
echo "$TOKEN" | cut -d'.' -f2 | base64 -d | jq '.scope'
# "crm-api/products.read crm-api/brands.read"

Solution 2: 403 on a write endpoint even though my token has the .write scope

Section titled “Solution 2: 403 on a write endpoint even though my token has the .write scope”

This is expected. Write endpoints (POST/PUT/DELETE on products and brands) require an admin web-app session, not just a scope. An M2M token carries no Cognito group membership, so these handlers return 403 regardless of the granted scope. The products.write / brands.write scopes exist and can be granted, but they do not unlock these operations for M2M clients.

Likewise, the Quotes, Templates, and Clients domains are part of the web application only — clients and quotes scopes cannot be granted to an M2M client at all (see Invalid Scope Error When Creating a Client above), and every /clients* route returns 403 to an M2M token regardless of scope. If your integration needs to create or modify data, or needs customer data, that workflow lives in the web app, not the M2M API.

Revoking a client blocks new token issuance immediately, and its already-issued tokens are rejected from the next API request — there is no propagation delay to wait out. If you’re still getting 403 after revoking and re-provisioning a client, check that you’re using the new client’s credentials, not a cached/hardcoded old client_id/client_secret. Access tokens issued before revocation are also rejected (403); if the revocation check is temporarily unavailable they are accepted until they expire.

M2M Access Token (for API calls):

Terminal window
curl -X GET /products \
-H "Authorization: Bearer <access-token>"

Admin ID Token (for client management):

Terminal window
curl -X POST /oauth/clients \
-H "Authorization: Bearer <id-token>"

Solution 5: 403 on GET /brands/overview even with brands.read

Section titled “Solution 5: 403 on GET /brands/overview even with brands.read”

GET /brands/overview is not part of the M2M API. It’s an internal aggregation endpoint used by the web app, and it’s explicitly denied to every M2M token regardless of which scopes the token carries — holding brands.read does not unlock it. The same denial applies to a small set of internal pricelist-import operations: POST /products/existing-for-preview, POST /products/batch-get, and POST /products/check-duplicates. If you need brand data, use GET /brands, GET /brands/:id, or GET /brands/search instead.


Common Causes:

  1. Invalid JSON in request body
  2. Missing required fields
  3. Invalid field values
  4. Wrong Content-Type header

Solutions:

Terminal window
# Test JSON validity
echo '{"clientName": "Test"}' | jq '.'
# Common JSON errors:
❌ {clientName: "Test"} # Missing quotes on key
❌ {"clientName": "Test",} # Trailing comma
❌ {"clientName": 'Test'} # Single quotes
✅ {"clientName": "Test"} # Valid

When creating M2M client:

{
"clientName": "Required (3-100 chars)",
"scopes": ["Required (at least one)"],
"description": "Optional",
"expiresInDays": "Optional (1-365)"
}

For JSON requests:

Content-Type: application/json

For token requests:

Content-Type: application/x-www-form-urlencoded

Full Error:

{
"error": "INVALID_SORT_FIELD",
"message": "Invalid sort field. Allowed fields: id, productCode, name, ... "
}

HTTP Status: 400 Bad Request

Causes:

  1. Sorting an M2M request by a field that isn’t exposed to M2M callers (e.g. sort=status)
  2. Sorting by a field that doesn’t exist at all

Solutions:

M2M requests can only sort by one of 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 — and most response fields are not sortable at all. The error message lists the exact allowed set for the resource you queried — use one of those values.

Web-app sessions (ID tokens) use the same sort column set; the M2M-specific part of the restriction is that each accepted column must map to a field on the external allowlist.

Solution 2: Use updatedAfter for Incremental Sync

Section titled “Solution 2: Use updatedAfter for Incremental Sync”

If you were trying to sort by updatedAt to drive an incremental sync, use the updatedAfter query parameter and/or sort=updatedAt instead of trying to read updatedAt out of the response body — it isn’t in the body (see below) but it is a valid sort field and query parameter.


Error: A field I expect is missing from the response

Section titled “Error: A field I expect is missing from the response”

Causes:

  1. The field isn’t part of the M2M external field allowlist
  2. fields= was used to try to request a field the allowlist excludes

Solutions:

M2M read responses for products and brands are projected through a server-side allowlist before being returned — an M2M caller never sees the full internal record, regardless of scope. The complete field lists:

Product fields: 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 (array of {url, name}), documents (array of {url, name}), erpCode, altSupplierCode, altSupplierCode2, webHierarchy, type, subType, subSubType, retailRange, and a projected relatedProducts array ({productId, productCode?, erpCode?, relationType?, qty?, source?}). 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.

Brand fields: id, name, slug.

Any field not in the allowlist above is never returned to an M2M token. There is no scope that unlocks it; a web-app session is required to see it. On relatedProducts entries, note, name, imageUrl, and archived are likewise never returned; productCode/erpCode are inline only on GET /products/{id}, not GET /products/search (see M2M Product Sync).

Solution 2: fields= Can Only Narrow, Never Widen

Section titled “Solution 2: fields= Can Only Narrow, Never Widen”

The fields= query parameter selects a subset of the response for M2M callers — it cannot be used to request a field outside the allowlist above. If you pass a denied field (e.g. fields=brandId), it’s silently dropped from the response rather than raising an error. If a field you asked for is missing, check it’s actually on the allowlist first before assuming something is broken.

Solution 3: updatedAt Is Not in the Response Body

Section titled “Solution 3: updatedAt Is Not in the Response Body”

updatedAt is deliberately absent from the projected response body — it isn’t on the allowlist — even though it’s still usable as a sort field and via the updatedAfter query parameter. If you need to know when a record last changed, drive your logic off updatedAfter/sort=updatedAt in the request rather than reading an updatedAt field back out of a record.


Causes:

  1. Wrong API endpoint URL
  2. Resource doesn’t exist
  3. Typo in resource ID

Solutions:

Correct:

https://{api-base-url}

Common mistakes:

❌ http://... (HTTP instead of HTTPS)
❌ .../products (missing /prod)
❌ .../api/products (extra /api)
Terminal window
# List all products first
curl -X GET "${API_URL}/products" \
-H "Authorization: Bearer $TOKEN" | jq '.products[].id'
# Then fetch specific product
curl -X GET "${API_URL}/products/prod-123" \
-H "Authorization: Bearer $TOKEN"

Full Error:

{
"message": "Too many requests"
}

HTTP Status: 429 Too Many Requests

Causes:

  1. Exceeded rate limits
  2. Too many concurrent requests
  3. Retry loop without backoff

Solutions:

async function requestWithRetry(url, options, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, options);
// Retry on 429 or 5xx errors
if (response.status === 429 || response.status >= 500) {
if (attempt === maxRetries - 1) throw new Error('Max retries exceeded');
// Exponential backoff: 1s, 2s, 4s
const delay = Math.pow(2, attempt) * 1000;
// Check Retry-After header if present
const retryAfter = response.headers.get('Retry-After');
const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : delay;
await new Promise(resolve => setTimeout(resolve, waitTime));
continue;
}
return response;
} catch (error) {
if (attempt === maxRetries - 1) throw error;
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
}
}
}

Instead of:

// ❌ 100 individual requests
for (const productId of productIds) {
await getProduct(productId);
}

Do:

// ✅ Single request with pagination
const products = await getProducts({ limit: 100 });
class RequestQueue {
constructor(maxConcurrent = 5) {
this.queue = [];
this.active = 0;
this.maxConcurrent = maxConcurrent;
}
async add(requestFn) {
if (this.active >= this.maxConcurrent) {
await new Promise(resolve => this.queue.push(resolve));
}
this.active++;
try {
return await requestFn();
} finally {
this.active--;
const next = this.queue.shift();
if (next) next();
}
}
}
// Usage
const queue = new RequestQueue(5);
for (const id of ids) {
await queue.add(() => getProduct(id));
}

Causes:

  1. Network connectivity issues
  2. API endpoint unreachable
  3. Firewall blocking requests
  4. DNS resolution failure

Solutions:

Terminal window
# Test DNS resolution
nslookup {api-base-url}
# Test HTTPS connectivity
curl -v https://{api-base-url}/health
# Check firewall rules
curl -v --max-time 10 https://{api-base-url}/products
// Node.js with axios
const response = await axios.get(url, {
timeout: 30000, // 30 seconds
});
// Python with requests
response = requests.get(url, timeout=30)
// PHP with cURL
curl_setopt($ch, CURLOPT_TIMEOUT, 30);

If behind corporate proxy:

Terminal window
# Set proxy environment variables
export HTTP_PROXY=http://proxy.company.com:8080
export HTTPS_PROXY=http://proxy.company.com:8080
# Test request
curl -x http://proxy.company.com:8080 https://api-url...

Causes:

  1. Outdated CA certificates
  2. System time incorrect
  3. SSL interception by proxy

Solutions:

Terminal window
# Ubuntu/Debian
sudo apt-get update
sudo apt-get install ca-certificates
# CentOS/RHEL
sudo yum update ca-certificates
# macOS
brew install ca-certificates
Terminal window
# Check system time
date
# Sync with NTP server
sudo ntpdate pool.ntp.org
Terminal window
# Check certificate chain
openssl s_client -connect {api-base-url}:443 -showcerts

Decode tokens to inspect claims:

Online: https://jwt.io

Command line:

Terminal window
# Decode JWT payload
echo "eyJraWQiOiJ..." | cut -d'.' -f2 | base64 -d | jq '.'

Node.js:

const jwt = require('jsonwebtoken');
const decoded = jwt.decode(token);
console.log(decoded);

cURL with verbose output:

Terminal window
curl -v -X GET https://api-url... \
-H "Authorization: Bearer $TOKEN" 2>&1 | tee debug.log

Postman with console:

  1. Open Postman Console (View → Show Postman Console)
  2. See request/response details
  3. Export logs for debugging

HTTPie (user-friendly cURL alternative):

Terminal window
http GET https://api-url... \
Authorization:"Bearer $TOKEN"

If you’re still stuck after trying these solutions:

Collect these details:

  • Exact error message and HTTP status code
  • Request details (method, endpoint, headers, body)
  • Client ID (never share client secret!)
  • Timestamp of the error

Email: support@tbc-app.com

Include:

  • Detailed problem description
  • Steps to reproduce
  • Information gathered from step 1
  • What you’ve already tried

For critical production issues:


Follow these practices to avoid common issues:

  • Cache access tokens (reuse them until expires_in elapses)
  • Implement exponential backoff for retries
  • Log errors with context (but never log secrets!)
  • Use HTTPS for all requests
  • Validate JSON before sending
  • Handle token expiration gracefully
  • Set reasonable timeouts
  • Monitor API usage and errors
  • Hardcode credentials in source code
  • Commit secrets to version control
  • Log client secrets or access tokens
  • Retry indefinitely without backoff
  • Ignore HTTP status codes
  • Use HTTP (always use HTTPS)
  • Request tokens on every API call
  • Share credentials via email/chat