Troubleshooting
Troubleshooting
Section titled “Troubleshooting”This guide covers common issues encountered when using the CRM M2M API and provides step-by-step solutions.
Quick Diagnostics
Section titled “Quick Diagnostics”Before diving into specific errors, run through this quick checklist:
-
Verify credentials
- ✅ Client ID is exactly 26 characters
- ✅ Client secret was copied correctly (no extra spaces/newlines)
- ✅ Client status is “active” (not revoked)
-
Check API configuration
- ✅ Using correct Cognito domain
- ✅ Using correct API base URL
- ✅ All requests use HTTPS (not HTTP)
-
Validate token
- ✅ Token was obtained successfully
- ✅ Token is within its
expires_inlifetime - ✅ Token includes required scopes
- ✅ Using
Authorization: Bearer <token>header
Authentication Errors
Section titled “Authentication Errors”Error: invalid_client
Section titled “Error: invalid_client”Full Error:
{ "error": "invalid_client", "error_description": "Client authentication failed"}HTTP Status: 400 Bad Request
Causes:
- Incorrect
client_idorclient_secret - Client has been revoked
- Extra whitespace in credentials
- Using ID token instead of credentials
Solutions:
Solution 1: Verify Credentials
Section titled “Solution 1: Verify Credentials”# 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 detailsSolution 2: Check Client Status
Section titled “Solution 2: Check Client Status”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
Solution 3: Regenerate Credentials
Section titled “Solution 3: Regenerate Credentials”If credentials are lost or potentially compromised:
- Create a new M2M client with the same scopes
- Update your application with new credentials
- Test with new client
- Revoke old client once migration is complete
Error: invalid_scope
Section titled “Error: invalid_scope”Full Error:
{ "error": "invalid_scope", "error_description": "Invalid scope"}HTTP Status: 400 Bad Request
Causes:
- Requesting scope not granted to client
- Typo in scope name (case-sensitive)
- Wrong scope format (missing
crm-api/prefix)
Solutions:
Solution 1: Check Granted Scopes
Section titled “Solution 1: Check Granted Scopes”Verify which scopes your client has:
- Log in to CRM admin panel
- Navigate to Settings → API Access
- Find your client
- Review “Scopes” column
Solution 2: Fix Scope Format
Section titled “Solution 2: Fix Scope Format”Correct format:
crm-api/products.read crm-api/brands.readCommon 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)Solution 3: Request Subset of Scopes
Section titled “Solution 3: Request Subset of Scopes”You don’t need to request all granted scopes:
# Client has: products.read, brands.read# Request only what you need:curl ... -d "scope=crm-api/products.read" # ✅ ValidSolution 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.readcrm-api/products.writecrm-api/brands.readcrm-api/brands.writeRequest 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.
Authorization Errors
Section titled “Authorization Errors”Error: 401 Unauthorized
Section titled “Error: 401 Unauthorized”Full Error:
{ "message": "Unauthorized"}HTTP Status: 401 Unauthorized
Causes:
- Missing Authorization header
- Token expired (older than its
expires_inlifetime) - Malformed Authorization header
- Using wrong token type (ID token vs access token)
Solutions:
Solution 1: Verify Header Format
Section titled “Solution 1: Verify Header Format”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)Solution 2: Check Token Expiration
Section titled “Solution 2: Check Token Expiration”Decode your JWT to check expiration:
# 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:
date -d @1732544400# Mon Nov 25 15:00:00 UTC 2025If expired, request a new token.
Solution 3: Implement Token Refresh Logic
Section titled “Solution 3: Implement Token Refresh Logic”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); }}Error: 403 Forbidden
Section titled “Error: 403 Forbidden”Full Error:
{ "message": "Forbidden"}HTTP Status: 403 Forbidden
Causes:
- Missing required scope for the endpoint
- Calling a write endpoint with an M2M token (writes are admin web-app only — see below)
- Calling a Quotes, Templates, or Clients endpoint with an M2M token
- Calling an endpoint that is explicitly denied to M2M tokens regardless of scope (see below)
- Client was recently revoked, and you’re still using its old credentials
Solutions:
Solution 1: Verify Required Scopes
Section titled “Solution 1: Verify Required Scopes”The M2M API is read-only. These are the M2M-usable endpoints and their scopes:
| Endpoint | Required Scope |
|---|---|
GET /products, /products/:id, /products/search | crm-api/products.read |
GET /brands, /brands/:id, /brands/search | crm-api/brands.read |
POST /oauth/clients | Admin group (ID token), not an M2M token |
Decode your token to verify scopes:
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.
Solution 3: Client Was Just Revoked
Section titled “Solution 3: Client Was Just Revoked”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.
Solution 4: Use Correct Token Type
Section titled “Solution 4: Use Correct Token Type”M2M Access Token (for API calls):
curl -X GET /products \ -H "Authorization: Bearer <access-token>"Admin ID Token (for client management):
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.
API Request Errors
Section titled “API Request Errors”Error: 400 Bad Request
Section titled “Error: 400 Bad Request”Common Causes:
- Invalid JSON in request body
- Missing required fields
- Invalid field values
- Wrong Content-Type header
Solutions:
Solution 1: Validate JSON
Section titled “Solution 1: Validate JSON”# Test JSON validityecho '{"clientName": "Test"}' | jq '.'
# Common JSON errors:❌ {clientName: "Test"} # Missing quotes on key❌ {"clientName": "Test",} # Trailing comma❌ {"clientName": 'Test'} # Single quotes✅ {"clientName": "Test"} # ValidSolution 2: Check Required Fields
Section titled “Solution 2: Check Required Fields”When creating M2M client:
{ "clientName": "Required (3-100 chars)", "scopes": ["Required (at least one)"], "description": "Optional", "expiresInDays": "Optional (1-365)"}Solution 3: Verify Content-Type
Section titled “Solution 3: Verify Content-Type”For JSON requests:
Content-Type: application/jsonFor token requests:
Content-Type: application/x-www-form-urlencodedError: 400 INVALID_SORT_FIELD
Section titled “Error: 400 INVALID_SORT_FIELD”Full Error:
{ "error": "INVALID_SORT_FIELD", "message": "Invalid sort field. Allowed fields: id, productCode, name, ... "}HTTP Status: 400 Bad Request
Causes:
- Sorting an M2M request by a field that isn’t exposed to M2M callers (e.g.
sort=status) - Sorting by a field that doesn’t exist at all
Solutions:
Solution 1: Sort by an Allowed Field
Section titled “Solution 1: Sort by an Allowed Field”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:
- The field isn’t part of the M2M external field allowlist
fields=was used to try to request a field the allowlist excludes
Solutions:
Solution 1: Check the Allowlist
Section titled “Solution 1: Check the Allowlist”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.
Error: 404 Not Found
Section titled “Error: 404 Not Found”Causes:
- Wrong API endpoint URL
- Resource doesn’t exist
- Typo in resource ID
Solutions:
Solution 1: Verify API Base URL
Section titled “Solution 1: Verify API Base URL”Correct:
https://{api-base-url}Common mistakes:
❌ http://... (HTTP instead of HTTPS)❌ .../products (missing /prod)❌ .../api/products (extra /api)Solution 2: Check Resource Exists
Section titled “Solution 2: Check Resource Exists”# List all products firstcurl -X GET "${API_URL}/products" \ -H "Authorization: Bearer $TOKEN" | jq '.products[].id'
# Then fetch specific productcurl -X GET "${API_URL}/products/prod-123" \ -H "Authorization: Bearer $TOKEN"Error: 429 Too Many Requests
Section titled “Error: 429 Too Many Requests”Full Error:
{ "message": "Too many requests"}HTTP Status: 429 Too Many Requests
Causes:
- Exceeded rate limits
- Too many concurrent requests
- Retry loop without backoff
Solutions:
Solution 1: Implement Exponential Backoff
Section titled “Solution 1: Implement Exponential Backoff”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)); } }}Solution 2: Batch Requests
Section titled “Solution 2: Batch Requests”Instead of:
// ❌ 100 individual requestsfor (const productId of productIds) { await getProduct(productId);}Do:
// ✅ Single request with paginationconst products = await getProducts({ limit: 100 });Solution 3: Use Request Queue
Section titled “Solution 3: Use Request Queue”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(); } }}
// Usageconst queue = new RequestQueue(5);for (const id of ids) { await queue.add(() => getProduct(id));}Network Errors
Section titled “Network Errors”Error: Connection Timeout
Section titled “Error: Connection Timeout”Causes:
- Network connectivity issues
- API endpoint unreachable
- Firewall blocking requests
- DNS resolution failure
Solutions:
Solution 1: Test Connectivity
Section titled “Solution 1: Test Connectivity”# Test DNS resolutionnslookup {api-base-url}
# Test HTTPS connectivitycurl -v https://{api-base-url}/health
# Check firewall rulescurl -v --max-time 10 https://{api-base-url}/productsSolution 2: Increase Timeout
Section titled “Solution 2: Increase Timeout”// Node.js with axiosconst response = await axios.get(url, { timeout: 30000, // 30 seconds});
// Python with requestsresponse = requests.get(url, timeout=30)
// PHP with cURLcurl_setopt($ch, CURLOPT_TIMEOUT, 30);Solution 3: Check Proxy Settings
Section titled “Solution 3: Check Proxy Settings”If behind corporate proxy:
# Set proxy environment variablesexport HTTP_PROXY=http://proxy.company.com:8080export HTTPS_PROXY=http://proxy.company.com:8080
# Test requestcurl -x http://proxy.company.com:8080 https://api-url...Error: SSL Certificate Error
Section titled “Error: SSL Certificate Error”Causes:
- Outdated CA certificates
- System time incorrect
- SSL interception by proxy
Solutions:
Solution 1: Update CA Certificates
Section titled “Solution 1: Update CA Certificates”# Ubuntu/Debiansudo apt-get updatesudo apt-get install ca-certificates
# CentOS/RHELsudo yum update ca-certificates
# macOSbrew install ca-certificatesSolution 2: Verify System Time
Section titled “Solution 2: Verify System Time”# Check system timedate
# Sync with NTP serversudo ntpdate pool.ntp.orgSolution 3: Verify SSL Certificate
Section titled “Solution 3: Verify SSL Certificate”# Check certificate chainopenssl s_client -connect {api-base-url}:443 -showcertsDebugging Tools
Section titled “Debugging Tools”JWT Decoder
Section titled “JWT Decoder”Decode tokens to inspect claims:
Online: https://jwt.io
Command line:
# Decode JWT payloadecho "eyJraWQiOiJ..." | cut -d'.' -f2 | base64 -d | jq '.'Node.js:
const jwt = require('jsonwebtoken');const decoded = jwt.decode(token);console.log(decoded);API Testing Tools
Section titled “API Testing Tools”cURL with verbose output:
curl -v -X GET https://api-url... \ -H "Authorization: Bearer $TOKEN" 2>&1 | tee debug.logPostman with console:
- Open Postman Console (View → Show Postman Console)
- See request/response details
- Export logs for debugging
HTTPie (user-friendly cURL alternative):
http GET https://api-url... \ Authorization:"Bearer $TOKEN"Getting Help
Section titled “Getting Help”If you’re still stuck after trying these solutions:
1. Gather Information
Section titled “1. Gather Information”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
2. Check Documentation
Section titled “2. Check Documentation”3. Contact Support
Section titled “3. Contact Support”Email: support@tbc-app.com
Include:
- Detailed problem description
- Steps to reproduce
- Information gathered from step 1
- What you’ve already tried
4. Emergency Contact
Section titled “4. Emergency Contact”For critical production issues:
- Email: support@tbc-app.com
Best Practices
Section titled “Best Practices”Follow these practices to avoid common issues:
- Cache access tokens (reuse them until
expires_inelapses) - 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
❌ DON’T:
Section titled “❌ DON’T:”- 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
Next Steps
Section titled “Next Steps”- Getting Started - Quick start guide
- Authentication - OAuth flow details
- Client Management - Managing clients
- Code Examples - Working code samples