NetSuite vs Odoo ERP REST API Comparison
Quality Management System Integration Reference
Document Type: API Integration Reference Created: 2026-02-15 Author: Claude (Sonnet 4.5) Purpose: Validate and improve QMS platform ERP adapter implementations Scope: Material master records, batch/lot records, quality holds synchronization
Executive Summary
This document provides a comprehensive comparison of NetSuite SuiteTalk REST Web Services and Odoo JSON-RPC APIs for integrating quality management systems with ERP platforms. Key focus areas include authentication mechanisms, record endpoints for inventory items, batch/lot tracking, quality holds, query filtering, and pagination strategies.
Critical Differences at a Glance
| Feature | NetSuite | Odoo |
|---|---|---|
| API Protocol | RESTful (SuiteTalk REST) | JSON-RPC (REST planned for removal in v20) |
| Authentication | OAuth 2.0 / Token-Based Auth (TBA) | API Keys / Session-based |
| Base URL Pattern | https://{account_id}.suitetalk.api.netsuite.com/services/rest/... | https://{domain}/jsonrpc |
| Pagination | Limit/offset (max 1000 per request) | Limit/offset (unlimited by default) |
| Rate Limiting | 15-55 concurrent requests (tier-based) | No official rate limits (instance-dependent) |
| Date Filtering | SuiteQL with to_date() function | Domain filters with operators |
| Query Language | SuiteQL (SQL-like) or REST record endpoints | Domain filters (Polish notation) |
NetSuite SuiteTalk REST Web Services API
1. Authentication
NetSuite supports two primary authentication mechanisms for REST API access:
OAuth 2.0 Authentication
Setup Requirements:
- Create an integration record in NetSuite (Setup → Integrations → Manage Integrations)
- Enable "REST WEB SERVICES" in the SuiteTalk (Web Services) section
- Enable "OAUTH 2.0" in the Manage Authentication section
- Obtain Client ID and Client Secret from the integration record
Two Grant Types:
A. Authorization Code Grant (User-interactive):
- Opens browser for explicit user authorization
- User selects role to connect with
- Generates access token on-the-fly
- Suitable for applications requiring user consent
B. Machine-to-Machine (M2M) (Non-interactive):
- No browser interaction required
- Automated service-to-service authentication
- Check "AUTHORIZATION CODE GRANT" under OAuth 2.0 subtab on Authentication tab
- Ideal for backend integrations and scheduled jobs
Token Endpoint:
POST https://{ACCOUNT_ID}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token
Required Headers:
Content-Type: application/x-www-form-urlencoded
Authorization: Basic {base64(client_id:client_secret)}
Request Body (M2M):
grant_type=client_credentials&scope=rest_webservices
Response:
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "rest_webservices"
}
Token-Based Authentication (TBA)
Alternative to OAuth 2.0, uses account-specific credentials:
- Consumer Key and Consumer Secret (from integration record)
- Token ID and Token Secret (generated per user/role)
- Signature-based authentication (OAuth 1.0a pattern)
2. Base URLs and Endpoints
Base URL Format:
https://{ACCOUNT_ID}.suitetalk.api.netsuite.com/services/rest/{service}/{version}/{resource}
Common Services:
record/v1/- CRUD operations on NetSuite recordsquery/v1/suiteql- SuiteQL queries (SQL-like)metadata-catalog/- Schema and field metadata
Replace {ACCOUNT_ID} with your NetSuite account ID (e.g., demo123, TSTDRV1234567).
3. Inventory Item Endpoints
Get Inventory Item by ID
GET https://{ACCOUNT_ID}.suitetalk.api.netsuite.com/services/rest/record/v1/inventoryItem/{ID}?expandSubResources=true
Required Headers:
Authorization: Bearer {access_token}
Content-Type: application/json
Prefer: transient
Query Parameters:
expandSubResources=true- Include related records (pricing, subsidiary list, etc.)fields={field1,field2}- Limit response to specific fields
Response Structure:
{
"id": "12345",
"itemId": "ITEM-001",
"displayName": "Example Inventory Item",
"isLotItem": false,
"isSerialItem": false,
"lastModifiedDate": "2026-02-15T10:30:00Z",
"subsidiary": [
{"id": "1", "refName": "Parent Company"}
],
"customFieldList": {
"customField": [
{
"scriptId": "custitem_quality_hold",
"value": "false"
}
]
}
}
Update Inventory Item
PATCH https://{ACCOUNT_ID}.suitetalk.api.netsuite.com/services/rest/record/v1/inventoryItem/{ID}
Request Body:
{
"displayName": "Updated Item Name",
"customFieldList": {
"customField": [
{
"scriptId": "custitem_quality_hold",
"value": "true"
}
]
}
}
Get Inventory Item Metadata
GET https://{ACCOUNT_ID}.suitetalk.api.netsuite.com/services/rest/record/v1/metadata-catalog/inventoryitem
Returns schema information including available fields, types, and custom fields.
4. Inventory Number / Lot Number Endpoints
Record Type: inventorynumber
Important Limitations:
- Cannot create standalone inventory number records via REST API
- Inventory numbers are created automatically when receiving items
- Use
receiptinventorynumberwithin item receipts and positive adjustments - Available only when "Serialized Inventory" or "Lot Tracking" feature is enabled
Get Inventory Number by ID:
GET https://{ACCOUNT_ID}.suitetalk.api.netsuite.com/services/rest/record/v1/inventorynumber/{ID}
Response Structure:
{
"id": "67890",
"inventoryNumber": "LOT-2026-001",
"item": {
"id": "12345",
"refName": "ITEM-001"
},
"quantityAvailable": "100.0",
"quantityOnHand": "100.0",
"expirationDate": "2027-02-15"
}
Important Fields:
inventoryNumber- The actual lot or serial number stringitem- Reference to the inventory itemquantityAvailable/quantityOnHand- Current inventory levelsexpirationDate- For lot-tracked items with expirationstatus- Active/Inactive status
Lot-Numbered vs Serialized Items:
- Controlled by flags on the
inventoryitemrecord islotitem: true- Enables lot trackingisserialitem: true- Enables serial number tracking- No separate REST record types required
5. Custom Records (Quality Holds)
Record Type: customrecord_{scriptid}
For quality holds, you'll typically use a custom record type created in NetSuite.
Get Custom Record:
GET https://{ACCOUNT_ID}.suitetalk.api.netsuite.com/services/rest/record/v1/customrecord_quality_hold/{ID}
Create Custom Record:
POST https://{ACCOUNT_ID}.suitetalk.api.netsuite.com/services/rest/record/v1/customrecord_quality_hold
Request Body:
{
"custrecord_related_item": {"id": "12345"},
"custrecord_lot_number": {"id": "67890"},
"custrecord_hold_reason": "Failed quality inspection",
"custrecord_hold_status": "Active",
"custrecord_hold_date": "2026-02-15"
}
Gotcha: Custom record script IDs must match exactly (case-sensitive), including the customrecord_ prefix.
6. Filtering by lastModifiedDate
Two Approaches:
A. Using SuiteQL (Recommended for complex queries)
POST https://{ACCOUNT_ID}.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql
Headers:
Authorization: Bearer {access_token}
Content-Type: application/json
Prefer: transient
Request Body:
{
"q": "SELECT id, itemid, displayname, lastmodifieddate FROM inventoryitem WHERE lastmodifieddate >= TO_DATE('2026-02-01 00:00:00', 'yyyy-mm-dd hh24:mi:ss') ORDER BY lastmodifieddate DESC"
}
Critical SuiteQL Rules:
- Cannot use date literals - Must use
TO_DATE()function - Date format:
'yyyy-mm-dd hh24:mi:ss' - Use
>=for "modified since" queries - Column names are case-insensitive but typically lowercase
Response:
{
"links": [
{"rel": "self", "href": "..."}
],
"count": 25,
"hasMore": true,
"items": [
{
"id": "12345",
"itemid": "ITEM-001",
"displayname": "Example Item",
"lastmodifieddate": "2026-02-15T10:30:00"
}
],
"offset": 0,
"totalResults": 250
}
B. Using Record API with Saved Searches
Create a saved search in NetSuite UI filtering by lastModifiedDate, then retrieve results:
GET https://{ACCOUNT_ID}.suitetalk.api.netsuite.com/services/rest/record/v1/inventoryItem?savedSearch={SEARCH_ID}
7. Pagination
Method: Limit/offset parameters in URL
For SuiteQL Queries:
POST https://{ACCOUNT_ID}.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql?limit=100&offset=0
For Record Endpoints:
GET https://{ACCOUNT_ID}.suitetalk.api.netsuite.com/services/rest/record/v1/inventoryItem?limit=100&offset=0
Hard Limits:
- Maximum 1000 records per request
- Response includes
hasMore: trueif additional pages exist totalResultsfield shows total matching recordsoffsetincrements bylimitfor next page
Pagination Loop Pattern:
offset = 0
limit = 1000 # Maximum allowed
all_items = []
while True:
response = api.get(f"/inventoryItem?limit={limit}&offset={offset}")
all_items.extend(response['items'])
if not response.get('hasMore'):
break
offset += limit
Gotcha: NetSuite doesn't use cursor-based pagination - always limit/offset. Large offsets can be slow.
8. Rate Limiting & Governance
Concurrency Limits:
- Default: 15 concurrent requests per account
- SuiteCloud Plus: +10 per license (e.g., 25, 35, 45...)
- Higher Tiers: Up to 55 concurrent requests (Tier 5)
- Shared across all integrations (SOAP, REST, RESTlet combined)
Frequency Limits:
- Applied over 60-second and 24-hour sliding windows
- No publicly documented request-per-minute cap (varies by tier)
Data Volume Limits:
- Max 1000 objects per request (enforced)
- Response payload size limits (undocumented, ~10MB typical)
Error Responses:
- HTTP 429 "Too Many Requests" - Rate limit exceeded
- HTTP 403 "Access Denied" - Concurrency limit exceeded (SOAP)
Governance Management:
- Setup → Integration → Integration Management → Integration Governance
- Allocate concurrency limits to specific integrations
- Monitor current usage and queue depth
Best Practices:
- Implement exponential backoff on 429 errors
- Use connection pooling (reuse TCP connections)
- Batch operations where possible
- Schedule large syncs during off-peak hours
9. Required Headers Summary
All Requests:
Authorization: Bearer {access_token}
Content-Type: application/json
Optional but Recommended:
Prefer: transient # Forces fresh (non-cached) results
Prefer: respond-async # Asynchronous processing for long-running queries
Asynchronous Pattern:
- Submit request with
Prefer: respond-async - Receive HTTP 202 with
Locationheader pointing to job status URL - Poll job status with
Prefer: transientheader - Retrieve results when status = "completed"
10. Common Pitfalls & Gotchas
Authentication:
- OAuth tokens expire (typically 1 hour) - Implement refresh logic
- Account ID must match the authorized account exactly
- Role permissions affect available records/fields
Date Handling:
- NetSuite stores dates in account timezone
- API returns ISO 8601 UTC timestamps
- SuiteQL requires
TO_DATE()- no date literals
Custom Fields:
- Use
scriptId(internal ID) not display name - Custom fields returned in
customFieldListarray - Must have permission to view/edit custom field
Inventory Numbers:
- Cannot create via REST - only query
- Created automatically during item receipt
- Lot/serial flags must be enabled on item first
Pagination:
- Responses may not include
totalResultsfor all record types - Large offsets (>10,000) can timeout - use date-based incremental sync instead
hasMoreis the reliable indicator for additional pages
Error Messages:
- Can be cryptic - Check NetSuite documentation for error codes
- Field validation errors reference internal field IDs
- 401 errors may indicate expired token OR insufficient permissions
Odoo JSON-RPC API
1. Authentication
Odoo uses session-based authentication via JSON-RPC protocol.
Endpoint:
POST https://{domain}/jsonrpc
Authentication Request Structure:
{
"jsonrpc": "2.0",
"method": "call",
"params": {
"service": "common",
"method": "authenticate",
"args": [
"database_name",
"username",
"password_or_api_key",
{}
]
},
"id": 1
}
Response:
{
"jsonrpc": "2.0",
"id": 1,
"result": 7 // User ID - use this in subsequent requests
}
Modern Best Practice (Odoo 14+): API Keys
- User generates API key from profile settings (User → Preferences → API Keys)
- Use API key in place of password in authentication request
- More secure - can be revoked without changing password
- Recommended for all integrations
Session Management:
- Authentication returns user ID (not a token)
- Session cookie is set automatically
- Include session cookie in all subsequent requests
- Sessions may expire after inactivity (configurable)
Important Note:
The XML-RPC and JSON-RPC APIs at endpoints
/xmlrpc,/xmlrpc/2, and/jsonrpcare scheduled for removal in Odoo 20 (fall 2026). Plan for migration to alternative API solutions.
2. Base URL and Endpoint
Single Endpoint for All Operations:
POST https://{domain}/jsonrpc
Common Domains:
- Self-hosted:
https://odoo.yourcompany.com - Odoo.sh:
https://yourinstance.odoo.com - Odoo Online:
https://yourdb.odoo.com
Port Specifications:
- HTTP: Typically port 8069 (development)
- HTTPS: Standard port 443 (production)
All operations (CRUD, search, authentication) use the same endpoint with different service and method parameters.
3. Product Template Endpoints (Material Master)
Model Name: product.template
Search and Read Products
Request:
{
"jsonrpc": "2.0",
"method": "call",
"params": {
"service": "object",
"method": "execute",
"args": [
"database_name",
7, // User ID from authentication
"password_or_api_key",
"product.template",
"search_read",
[], // Domain filter (empty = all records)
["id", "name", "default_code", "write_date", "type"] // Fields to retrieve
]
},
"id": 2
}
Response:
{
"jsonrpc": "2.0",
"id": 2,
"result": [
{
"id": 123,
"name": "Example Product",
"default_code": "PROD-001",
"write_date": "2026-02-15 10:30:00",
"type": "product"
}
]
}
Key Fields:
id- Internal record IDname- Product namedefault_code- Internal reference / SKUwrite_date- Last modification timestamptype- Product type:'product'(stockable),'consu'(consumable),'service'tracking- Lot/serial tracking:'none','lot','serial'
Create Product
Request:
{
"jsonrpc": "2.0",
"method": "call",
"params": {
"service": "object",
"method": "execute",
"args": [
"database_name",
7,
"password_or_api_key",
"product.template",
"create",
{
"name": "New Product",
"default_code": "PROD-002",
"type": "product",
"tracking": "lot"
}
]
},
"id": 3
}
Response:
{
"jsonrpc": "2.0",
"id": 3,
"result": 124 // ID of newly created record
}
Update Product
Request:
{
"jsonrpc": "2.0",
"method": "call",
"params": {
"service": "object",
"method": "execute",
"args": [
"database_name",
7,
"password_or_api_key",
"product.template",
"write",
[123], // Record ID(s) to update
{
"name": "Updated Product Name"
}
]
},
"id": 4
}
Response:
{
"jsonrpc": "2.0",
"id": 4,
"result": true
}
4. Stock Lot Endpoints (Batch/Lot Records)
Model Name: stock.lot (also known as stock.production.lot)
Search and Read Lots
Request:
{
"jsonrpc": "2.0",
"method": "call",
"params": {
"service": "object",
"method": "execute",
"args": [
"database_name",
7,
"password_or_api_key",
"stock.lot",
"search_read",
[["product_id", "=", 123]], // Domain: filter by product
["id", "name", "product_id", "ref", "create_date", "write_date"]
]
},
"id": 5
}
Response:
{
"jsonrpc": "2.0",
"id": 5,
"result": [
{
"id": 456,
"name": "LOT-2026-001",
"product_id": [123, "Example Product"],
"ref": "EXT-REF-123",
"create_date": "2026-02-01 08:00:00",
"write_date": "2026-02-15 10:30:00"
}
]
}
Key Fields:
id- Lot/batch internal IDname- Lot/batch number (user-visible identifier)product_id- Related product (many2one:[id, name])ref- External reference (optional)create_date- Creation timestampwrite_date- Last modification timestampuse_date- Expiration/use-by dateremoval_date- Removal datealert_date- Alert date for expiring lotscompany_id- Company (for multi-company setups)
Create Lot/Batch
Request:
{
"jsonrpc": "2.0",
"method": "call",
"params": {
"service": "object",
"method": "execute",
"args": [
"database_name",
7,
"password_or_api_key",
"stock.lot",
"create",
{
"name": "LOT-2026-002",
"product_id": 123,
"company_id": 1
}
]
},
"id": 6
}
5. Quality Check Endpoints (Quality Holds)
Model Names:
quality.check- Quality control checksquality.alert- Quality alerts/issues
Important: Quality module must be installed. Not all Odoo installations include quality management.
Search Quality Checks
Request:
{
"jsonrpc": "2.0",
"method": "call",
"params": {
"service": "object",
"method": "execute",
"args": [
"database_name",
7,
"password_or_api_key",
"quality.check",
"search_read",
[["product_id", "=", 123]],
["id", "name", "product_id", "lot_id", "quality_state", "test_type"]
]
},
"id": 7
}
Key Fields (quality.check):
id- Check IDname- Check referenceproduct_id- Related productlot_id- Related lot/batch (if applicable)quality_state- State:'none','pass','fail','measure'test_type- Type:'instructions','picture','measure','passfail','worksheet'picking_id- Related inventory operationuser_id- Responsible user
Search Quality Alerts
Request:
{
"jsonrpc": "2.0",
"method": "call",
"params": {
"service": "object",
"method": "execute",
"args": [
"database_name",
7,
"password_or_api_key",
"quality.alert",
"search_read",
[["stage_id.name", "=", "Active"]],
["id", "name", "product_id", "lot_id", "priority", "date_assign"]
]
},
"id": 8
}
Key Fields (quality.alert):
id- Alert IDname- Alert title/referenceproduct_id- Related productlot_id- Related lot/batchpriority- Priority level (1-3)date_assign- Assignment datestage_id- Alert stage (workflow state)root_cause- Root cause of issuedescription- Detailed descriptionvendor_id- Vendor (if vendor-related issue)
6. Domain Filter Syntax
Odoo uses domain filters in Polish (prefix) notation for complex queries.
Basic Syntax:
Each condition is a three-element tuple: (field_name, operator, value)
Common Operators:
=- Equals!=- Not equals>,<,>=,<=- Comparisonin,not in- List membershiplike,ilike- Text search (case-sensitive / case-insensitive)=like,=ilike- Pattern matching with wildcards
Examples:
Simple Filter (Single Condition):
[('name', '=', 'LOT-2026-001')]
Multiple Conditions (AND logic by default):
[
('product_id', '=', 123),
('create_date', '>=', '2026-02-01'),
('create_date', '<=', '2026-02-15')
]
Filtering by write_date (Last Modified):
[('write_date', '>', '2026-02-01 00:00:00')]
Complex Logic with OR (Polish Notation):
[
'|', # OR operator (prefix)
('field1', '=', 'A'),
('field2', '=', 'B')
]
# Equivalent to: field1='A' OR field2='B'
Combining AND and OR:
[
'&', # AND operator (explicit)
'|', # OR operator
('field1', '=', 'A'),
('field2', '=', 'B'),
('field3', '=', 'C')
]
# Equivalent to: (field1='A' OR field2='B') AND field3='C'
Polish Notation Rules:
- Operators come before operands
&= AND (often implicit between consecutive tuples)|= OR!= NOT- Operators are prefix - placed before the conditions they operate on
Gotchas:
- Default between consecutive tuples is AND (no explicit
&needed) - Date strings must match Odoo's format:
'YYYY-MM-DD HH:MM:SS' - Many2one fields: filter by ID, not name (use
field.nameto filter by related name) - Empty domain
[]returns all records
7. Pagination
Method: Limit and offset parameters in args
Pagination Pattern:
{
"jsonrpc": "2.0",
"method": "call",
"params": {
"service": "object",
"method": "execute",
"args": [
"database_name",
7,
"password_or_api_key",
"product.template",
"search_read",
[],
["id", "name"],
{
"limit": 100, // Records per page
"offset": 0 // Starting record (0-indexed)
}
]
},
"id": 9
}
Offset Calculation:
# Page 1: offset=0, limit=100 (records 0-99)
# Page 2: offset=100, limit=100 (records 100-199)
# Page 3: offset=200, limit=100 (records 200-299)
No Hard Limits:
- Unlike NetSuite, Odoo doesn't enforce a maximum limit per request
- Practical limits depend on instance resources and timeout settings
- Recommend: 100-1000 records per page for reliability
Determining Total Count:
Use separate search_count call:
{
"jsonrpc": "2.0",
"method": "call",
"params": {
"service": "object",
"method": "execute",
"args": [
"database_name",
7,
"password_or_api_key",
"product.template",
"search_count",
[("type", "=", "product")]
]
},
"id": 10
}
Response:
{
"jsonrpc": "2.0",
"id": 10,
"result": 1250 // Total matching records
}
Pagination Loop Pattern:
limit = 100
offset = 0
all_records = []
# Get total count first
total = odoo.search_count('product.template', domain)
while offset < total:
records = odoo.search_read(
'product.template',
domain,
fields,
limit=limit,
offset=offset
)
all_records.extend(records)
offset += limit
8. Required Headers
All Requests:
Content-Type: application/json
Optional (for session management):
Cookie: session_id={session_id}
cURL Example:
curl -i -X POST \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "call",
"params": {
"service": "object",
"method": "execute",
"args": ["db_name", 7, "api_key", "product.template", "search_read", [], ["id", "name"]]
},
"id": 1
}' \
https://odoo.yourcompany.com/jsonrpc
9. Rate Limiting
Official Rate Limits: None documented in standard Odoo
Instance-Dependent Factors:
- Server resources (CPU, RAM)
- Database size and complexity
- Concurrent user load
- Network bandwidth
Odoo.sh and Odoo Online:
- May have undocumented throttling
- Typically based on resource usage rather than request count
- No public documentation on limits
Best Practices:
- Implement retry logic for timeouts
- Use batch operations where possible
- Limit concurrent connections (5-10 recommended)
- Monitor instance performance and adjust accordingly
Error Handling:
- HTTP timeouts (configurable, default ~60-120s)
- Database connection pool exhaustion
- No standard 429 "Too Many Requests" response
10. Common Pitfalls & Gotchas
Authentication:
- API keys recommended over passwords (Odoo 14+)
- User ID from authentication must be included in all subsequent calls
- Session cookies may expire - implement re-authentication logic
- Permission errors are common - ensure user has model access rights
JSON-RPC Deprecation:
/jsonrpcendpoint scheduled for removal in Odoo 20 (fall 2026)- Plan migration strategy - no official REST replacement announced yet
- Community REST API modules exist but are third-party
Domain Filters:
- Polish notation is confusing - test filters carefully
- Implicit AND between tuples - explicit
&rarely needed - Date format must be exact:
'YYYY-MM-DD HH:MM:SS' - Many2one fields return
[id, display_name]- access by index
Model Access:
- Quality module (
quality.check,quality.alert) requires separate installation - Custom fields access depends on user permissions
- Some models are restricted to specific user groups
Field References:
- Use technical field names, not labels (e.g.,
default_codenot "Internal Reference") - Many2one fields:
product_idnotproduct - Related fields: Use dot notation
lot_id.product_id.name
Response Errors:
- Error messages in
errorfield, notresult - May not include HTTP error codes - always check JSON structure
- Traceback often included in error response (verbose)
Date/Time Handling:
- Odoo stores in UTC by default
- User timezone conversions handled server-side
create_dateandwrite_datealways in UTC- Custom date fields may have different timezone behavior
Pagination:
- No
hasMoreindicator - usesearch_countto determine total - Large offsets can be slow (database performance issue)
- Consider date-based incremental sync for large datasets
Side-by-Side Integration Patterns
Incremental Sync by Last Modified Date
NetSuite (SuiteQL):
import requests
from datetime import datetime, timedelta
def sync_netsuite_items(access_token, account_id, last_sync_date):
url = f"https://{account_id}.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"Prefer": "transient"
}
query = f"""
SELECT id, itemid, displayname, lastmodifieddate
FROM inventoryitem
WHERE lastmodifieddate >= TO_DATE('{last_sync_date}', 'yyyy-mm-dd hh24:mi:ss')
ORDER BY lastmodifieddate ASC
"""
offset = 0
limit = 1000
all_items = []
while True:
response = requests.post(
f"{url}?limit={limit}&offset={offset}",
headers=headers,
json={"q": query}
)
response.raise_for_status()
data = response.json()
all_items.extend(data['items'])
if not data.get('hasMore'):
break
offset += limit
return all_items
Odoo (JSON-RPC):
import requests
def sync_odoo_products(db, uid, api_key, url, last_sync_date):
endpoint = f"{url}/jsonrpc"
headers = {"Content-Type": "application/json"}
# Domain filter for modified products
domain = [('write_date', '>', last_sync_date)]
# Get total count
count_payload = {
"jsonrpc": "2.0",
"method": "call",
"params": {
"service": "object",
"method": "execute",
"args": [db, uid, api_key, "product.template", "search_count", domain]
},
"id": 1
}
count_response = requests.post(endpoint, json=count_payload, headers=headers)
total = count_response.json()['result']
# Paginate through results
limit = 100
offset = 0
all_products = []
while offset < total:
search_payload = {
"jsonrpc": "2.0",
"method": "call",
"params": {
"service": "object",
"method": "execute",
"args": [
db, uid, api_key,
"product.template",
"search_read",
domain,
["id", "name", "default_code", "write_date"],
{"limit": limit, "offset": offset}
]
},
"id": 2
}
response = requests.post(endpoint, json=search_payload, headers=headers)
products = response.json()['result']
all_products.extend(products)
offset += limit
return all_products
Error Handling and Retry Logic
NetSuite (with rate limit handling):
import time
def netsuite_request_with_retry(url, headers, json_data=None, max_retries=3):
for attempt in range(max_retries):
try:
if json_data:
response = requests.post(url, headers=headers, json=json_data)
else:
response = requests.get(url, headers=headers)
if response.status_code == 429: # Rate limit
retry_after = int(response.headers.get('Retry-After', 60))
print(f"Rate limited. Waiting {retry_after}s...")
time.sleep(retry_after)
continue
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
if attempt == max_retries - 1:
raise
wait_time = 2 ** attempt # Exponential backoff
time.sleep(wait_time)
raise Exception("Max retries exceeded")
Odoo (with error response parsing):
def odoo_request_with_retry(url, payload, max_retries=3):
for attempt in range(max_retries):
try:
response = requests.post(
url,
json=payload,
headers={"Content-Type": "application/json"}
)
response.raise_for_status()
result = response.json()
# Check for JSON-RPC error
if 'error' in result:
error = result['error']
error_msg = error.get('data', {}).get('message', str(error))
raise Exception(f"Odoo API Error: {error_msg}")
return result.get('result')
except requests.exceptions.RequestException as e:
if attempt == max_retries - 1:
raise
time.sleep(2 ** attempt)
raise Exception("Max retries exceeded")
Quality Management System Integration Checklist
NetSuite Integration Tasks
-
Authentication Setup
- Create integration record in NetSuite
- Enable OAuth 2.0 and REST Web Services
- Generate and securely store Client ID and Client Secret
- Implement token refresh logic (1-hour expiration)
-
Material Master Sync
- Map QMS fields to NetSuite
inventoryitemcustom fields - Implement lastModifiedDate filtering via SuiteQL
- Handle pagination (1000 record limit)
- Test with lot-tracked items (
islotitem: true)
- Map QMS fields to NetSuite
-
Lot/Batch Sync
- Query
inventorynumberrecords (read-only) - Map lot numbers to QMS batch records
- Handle expiration dates for lot-tracked items
- Test with serialized items (
isserialitem: true)
- Query
-
Quality Hold Implementation
- Create custom record type for quality holds
- Define custom fields (related item, lot, reason, status)
- Implement CRUD operations via REST API
- Test workflow integration
-
Error Handling
- Implement 429 rate limit retry logic
- Handle 401 token expiration errors
- Log concurrency limit (429/403) errors
- Monitor governance dashboard
Odoo Integration Tasks
-
Authentication Setup
- Generate API keys for integration user
- Test authentication endpoint
- Implement session management
- Plan for JSON-RPC deprecation (Odoo 20)
-
Material Master Sync
- Map QMS fields to
product.templatefields - Implement write_date filtering with domain syntax
- Test with different product types (stockable, consumable)
- Validate tracking settings (
'lot','serial','none')
- Map QMS fields to
-
Lot/Batch Sync
- Query
stock.lotmodel - Map lot fields (name, product_id, dates)
- Handle expiration/alert dates
- Test lot creation and updates
- Query
-
Quality Check Integration
- Verify quality module installation
- Map
quality.checkfields to QMS - Map
quality.alertfields for hold workflow - Test quality state transitions
-
Error Handling
- Parse JSON-RPC error responses
- Handle permission/access errors
- Implement timeout retry logic
- Log model access violations
Conclusion
Both NetSuite and Odoo provide robust APIs for ERP integration, but with significantly different approaches:
NetSuite Strengths:
- True RESTful API with predictable endpoint structure
- Strong OAuth 2.0 authentication
- SuiteQL provides SQL-like querying flexibility
- Clear pagination and rate limiting documentation
NetSuite Challenges:
- Concurrency and frequency limits require careful management
- 1000 record pagination limit
- Inventory number records are read-only via API
- Complex custom field mapping
Odoo Strengths:
- Simple JSON-RPC protocol (though deprecated)
- Flexible domain filter syntax
- No enforced rate limits
- Direct model access with consistent patterns
Odoo Challenges:
- JSON-RPC deprecation in Odoo 20 (2026)
- Polish notation domain filters are non-intuitive
- Quality module not always installed
- No standard rate limiting (instance-dependent)
Recommendation for QMS Integration:
- For NetSuite: Use SuiteQL for complex queries, implement robust rate limit handling, and use date-based incremental syncs to avoid pagination issues.
- For Odoo: Leverage domain filters for precise queries, prepare for API migration post-Odoo 19, and use
search_countfor pagination planning.
Both systems require careful field mapping and thorough testing of edge cases (lot-tracked items, expiration dates, quality holds). Implement comprehensive logging and monitoring for production deployments.
Sources
NetSuite API Documentation
- Oracle NetSuite - OAuth 2.0 for REST Web Services
- Oracle NetSuite - OAuth 2.0 Overview
- Modern Treasury - How to Authenticate to NetSuite's SuiteTalk REST Web Services API
- Houseblend - Optimizing High-Volume NetSuite REST API Integrations
- Nanonets - The Complete Guide to the NetSuite REST API
- Folio3 - Getting Started with NetSuite SuiteTalk REST API in Postman
- Apideck - A Guide to Integrating with the NetSuite REST API
- Oracle NetSuite - Inventory Item Documentation
- Oracle NetSuite - Inventory Number Documentation
- Oracle NetSuite - Lot Numbered Inventory Item
- Houseblend - NetSuite API Governance: Concurrency & Rate Limits Explained
- Coefficient - NetSuite API Rate Limits & How to Prevent Hitting Them
- Oracle NetSuite - Executing SuiteQL Queries Through REST Web Services
- Oracle NetSuite - Searching by lastModifiedDate
- Suite Answers That Work - Using SuiteQL for the REST API in NetSuite
Odoo API Documentation
- Odoo 19.0 - External JSON-RPC API
- Odoo 18.0 - Web Services
- GitHub - amlaksil/Odoo-JSON-RPC-API
- GetKnit - Odoo API Integration Guide (In-Depth)
- IndexWorld - How to Use JSON-RPC in Odoo 17: A Step-by-Step Guide
- Odoo Forum - External API Documentation (JSON-RPC)
- Odoo Forum - Explaination of Odoo Domain Filter with Simple Example
- Braincuber - Writing Effective Domain Filters in Odoo: Complex Query Patterns
- Cybrosys - How to Use Search Domain Operators in Odoo 17
- Odoo - Quality Alerts Documentation
- Odoo - Quality Module Overview
- Cybrosys - An Overview of Quality Checks & Quality Alerts With Odoo 16
Document Version: 1.0 Last Updated: 2026-02-15 Next Review: Upon adapter implementation completion