Skip to main content

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

FeatureNetSuiteOdoo
API ProtocolRESTful (SuiteTalk REST)JSON-RPC (REST planned for removal in v20)
AuthenticationOAuth 2.0 / Token-Based Auth (TBA)API Keys / Session-based
Base URL Patternhttps://{account_id}.suitetalk.api.netsuite.com/services/rest/...https://{domain}/jsonrpc
PaginationLimit/offset (max 1000 per request)Limit/offset (unlimited by default)
Rate Limiting15-55 concurrent requests (tier-based)No official rate limits (instance-dependent)
Date FilteringSuiteQL with to_date() functionDomain filters with operators
Query LanguageSuiteQL (SQL-like) or REST record endpointsDomain 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:

  1. Create an integration record in NetSuite (Setup → Integrations → Manage Integrations)
  2. Enable "REST WEB SERVICES" in the SuiteTalk (Web Services) section
  3. Enable "OAUTH 2.0" in the Manage Authentication section
  4. 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 records
  • query/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 receiptinventorynumber within 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 string
  • item - Reference to the inventory item
  • quantityAvailable / quantityOnHand - Current inventory levels
  • expirationDate - For lot-tracked items with expiration
  • status - Active/Inactive status

Lot-Numbered vs Serialized Items:

  • Controlled by flags on the inventoryitem record
  • islotitem: true - Enables lot tracking
  • isserialitem: 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:

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: true if additional pages exist
  • totalResults field shows total matching records
  • offset increments by limit for 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:

  1. Submit request with Prefer: respond-async
  2. Receive HTTP 202 with Location header pointing to job status URL
  3. Poll job status with Prefer: transient header
  4. 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 customFieldList array
  • 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 totalResults for all record types
  • Large offsets (>10,000) can timeout - use date-based incremental sync instead
  • hasMore is 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 /jsonrpc are 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 ID
  • name - Product name
  • default_code - Internal reference / SKU
  • write_date - Last modification timestamp
  • type - 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 ID
  • name - Lot/batch number (user-visible identifier)
  • product_id - Related product (many2one: [id, name])
  • ref - External reference (optional)
  • create_date - Creation timestamp
  • write_date - Last modification timestamp
  • use_date - Expiration/use-by date
  • removal_date - Removal date
  • alert_date - Alert date for expiring lots
  • company_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 checks
  • quality.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 ID
  • name - Check reference
  • product_id - Related product
  • lot_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 operation
  • user_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 ID
  • name - Alert title/reference
  • product_id - Related product
  • lot_id - Related lot/batch
  • priority - Priority level (1-3)
  • date_assign - Assignment date
  • stage_id - Alert stage (workflow state)
  • root_cause - Root cause of issue
  • description - Detailed description
  • vendor_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
  • >, <, >=, <= - Comparison
  • in, not in - List membership
  • like, 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.name to 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:

  • /jsonrpc endpoint 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_code not "Internal Reference")
  • Many2one fields: product_id not product
  • Related fields: Use dot notation lot_id.product_id.name

Response Errors:

  • Error messages in error field, not result
  • 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_date and write_date always in UTC
  • Custom date fields may have different timezone behavior

Pagination:

  • No hasMore indicator - use search_count to 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 inventoryitem custom fields
    • Implement lastModifiedDate filtering via SuiteQL
    • Handle pagination (1000 record limit)
    • Test with lot-tracked items (islotitem: true)
  • Lot/Batch Sync

    • Query inventorynumber records (read-only)
    • Map lot numbers to QMS batch records
    • Handle expiration dates for lot-tracked items
    • Test with serialized items (isserialitem: true)
  • 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.template fields
    • Implement write_date filtering with domain syntax
    • Test with different product types (stockable, consumable)
    • Validate tracking settings ('lot', 'serial', 'none')
  • Lot/Batch Sync

    • Query stock.lot model
    • Map lot fields (name, product_id, dates)
    • Handle expiration/alert dates
    • Test lot creation and updates
  • Quality Check Integration

    • Verify quality module installation
    • Map quality.check fields to QMS
    • Map quality.alert fields 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_count for 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

Odoo API Documentation


Document Version: 1.0 Last Updated: 2026-02-15 Next Review: Upon adapter implementation completion