Odoo Integration Best Practices for Enterprise Environments - AegeanFlows
A technical guide to Odoo API integration patterns, authentication, error handling, and performance optimisation for connecting Odoo 17 to SAP, ERPs, and third-party systems.
Introduction
Odoo’s XML-RPC and JSON-RPC APIs are powerful enough for production integrations, but they require discipline. Poorly structured integrations fail silently, suffer from race conditions, and become maintenance nightmares as Odoo versions change. This guide documents the patterns we use in production at AegeanFlows for connecting Odoo 17 to SAP, external marketplaces, and third-party logistics systems.
Authentication: XML-RPC vs JSON-RPC
Odoo exposes two API endpoints:
/xmlrpc/2/common— authentication and server info/xmlrpc/2/object— model operations (read, write, create, unlink)
For new integrations, prefer JSON-RPC via the /web/dataset/call_kw endpoint. It handles large payloads better and is easier to debug with standard HTTP tooling.
import requests
import json
class OdooClient:
def __init__(self, url: str, db: str, username: str, api_key: str):
self.url = url.rstrip('/')
self.db = db
self.uid = self._authenticate(username, api_key)
self.api_key = api_key
def _authenticate(self, username: str, password: str) -> int:
payload = {
"jsonrpc": "2.0",
"method": "call",
"params": {
"db": self.db,
"login": username,
"password": password,
}
}
response = requests.post(
f"{self.url}/web/session/authenticate",
json=payload,
timeout=30
)
result = response.json()
if result.get('error'):
raise ConnectionError(f"Auth failed: {result['error']['data']['message']}")
return result['result']['uid']
def execute(self, model: str, method: str, *args, **kwargs) -> any:
payload = {
"jsonrpc": "2.0",
"method": "call",
"params": {
"model": model,
"method": method,
"args": list(args),
"kwargs": kwargs,
}
}
response = requests.post(
f"{self.url}/web/dataset/call_kw",
json=payload,
timeout=60
)
result = response.json()
if result.get('error'):
raise RuntimeError(f"Odoo error: {result['error']['data']['message']}")
return result['result']
Use API keys, not passwords. Odoo 14+ supports API key authentication. Generate a key under Settings → Technical → API Keys. This allows you to rotate credentials without changing passwords and to scope keys to specific integrations.
Domain Filters: Writing Correct Search Queries
Odoo’s domain syntax is a Lisp-like prefix notation. The most common mistake is incorrect use of logical operators.
# WRONG: This searches for records where name = 'SAP' OR active = True
# (implicit OR with multiple tuples)
domain = [('name', '=', 'SAP'), ('active', '=', True)]
# CORRECT: Explicit AND
domain = ['&', ('name', '=', 'SAP'), ('active', '=', True)]
# Three conditions with AND
domain = ['&', '&',
('state', '=', 'posted'),
('partner_id.country_id.code', '=', 'GR'),
('amount_total', '>', 1000)
]
# Complex: (state = 'draft' OR state = 'posted') AND partner_id is set
domain = ['&',
'|', ('state', '=', 'draft'), ('state', '=', 'posted'),
('partner_id', '!=', False)
]
Always test domains in the Odoo shell first:
# In Odoo shell (./odoo-bin shell -d mydb)
env['account.move'].search_count([('state', '=', 'posted')])
Batch Operations: Never Loop Over Single Records
The single biggest performance issue in Odoo integrations is calling write() or create() in a loop:
# SLOW: N database round-trips
for product_data in product_list:
odoo.execute('product.product', 'create', product_data)
# FAST: Single call with list of dicts (Odoo 14+)
odoo.execute('product.product', 'create', product_list)
For updates, use write() on a multi-record recordset:
# Find all products needing price update
product_ids = odoo.execute(
'product.product', 'search',
[('categ_id.name', '=', 'SAP-Sync'), ('active', '=', True)]
)
# Update all in a single call
if product_ids:
odoo.execute(
'product.product', 'write',
product_ids,
{'standard_price': new_price, 'lst_price': new_price * 1.2}
)
For very large datasets (10,000+ records), chunk into batches of 500–1,000:
def batch_write(client, model, ids, values, chunk_size=500):
for i in range(0, len(ids), chunk_size):
chunk = ids[i:i + chunk_size]
client.execute(model, 'write', chunk, values)
Idempotency: Designing for Retry Safety
Integrations fail. Networks drop. SAP times out. Your integration must handle re-runs without creating duplicate records.
Pattern 1: External reference field
Add an x_external_ref field to the Odoo model. Before creating, search for an existing record:
def upsert_partner(client, sap_bp_id: str, data: dict) -> int:
existing = client.execute(
'res.partner', 'search',
[('x_sap_bp_id', '=', sap_bp_id)]
)
if existing:
client.execute('res.partner', 'write', existing, data)
return existing[0]
else:
data['x_sap_bp_id'] = sap_bp_id
return client.execute('res.partner', 'create', data)
Pattern 2: Sequence-based deduplication
For high-volume event streams (e.g. IoT events, marketplace orders), use a message sequence number stored in a dedicated log table:
class IntegrationLog(models.Model):
_name = 'integration.log'
source_system = fields.Char(required=True)
message_id = fields.Char(required=True)
processed_at = fields.Datetime(default=fields.Datetime.now)
_sql_constraints = [
('unique_message',
'UNIQUE(source_system, message_id)',
'Duplicate message detected')
]
Error Handling and Alerting
Production integrations need structured error handling. Silent failures (where exceptions are swallowed and the integration appears to succeed) are worse than hard failures.
import logging
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger('odoo_integration')
@dataclass
class IntegrationResult:
success: bool
record_id: Optional[int] = None
error_message: Optional[str] = None
retry_eligible: bool = False
def safe_create_invoice(client, invoice_data: dict) -> IntegrationResult:
try:
invoice_id = client.execute('account.move', 'create', invoice_data)
logger.info(f"Created invoice {invoice_id}")
return IntegrationResult(success=True, record_id=invoice_id)
except RuntimeError as e:
error_str = str(e)
# Distinguish between data errors (don't retry) and transient errors (retry)
if 'unique constraint' in error_str.lower():
logger.warning(f"Duplicate invoice detected: {error_str}")
return IntegrationResult(success=False, error_message=error_str, retry_eligible=False)
else:
logger.error(f"Invoice creation failed: {error_str}")
return IntegrationResult(success=False, error_message=error_str, retry_eligible=True)
except requests.Timeout:
logger.error("Odoo connection timeout")
return IntegrationResult(success=False, error_message="Timeout", retry_eligible=True)
SAP ↔ Odoo: Field Mapping Reference
When integrating SAP Business Partners with Odoo partners, the key mappings are:
| SAP Field | SAP Table/Field | Odoo Model | Odoo Field |
|---|---|---|---|
| BP Number | BUT000.PARTNER | res.partner | x_sap_bp_id |
| Organisation name | BUT000.NAME_ORG1 | res.partner | name |
| Country | BUT000.LAND1 | res.partner | country_id (Many2one) |
| VAT number | DFKKBPTAXNUM.TAXNUM | res.partner | vat |
| Payment terms | T052.ZTERM | res.partner | property_payment_term_id |
| Credit limit | KNB1.KLIMK | res.partner | credit_limit |
The country mapping requires a lookup from ISO code to Odoo’s res.country ID:
def get_country_id(client, iso_code: str) -> Optional[int]:
countries = client.execute(
'res.country', 'search_read',
[('code', '=', iso_code.upper())],
{'fields': ['id'], 'limit': 1}
)
return countries[0]['id'] if countries else None
Webhooks vs Polling: Choosing the Right Pattern
Use webhooks when:
- Odoo is the source of truth (e.g. a sale order created in Odoo must trigger a SAP delivery)
- Near real-time latency is required (< 30 seconds)
- The target system can accept HTTP callbacks
Use polling when:
- The source system doesn’t support webhooks (many legacy SAP systems)
- You need guaranteed delivery with retry semantics
- Message ordering matters
For SAP-to-Odoo flows, we typically use a polling approach with a dedicated integration middleware (SAP BTP Integration Suite), pulling delta records every 5–15 minutes using SAP OData change sets or timestamp-based queries.
Performance: What Limits Odoo API Throughput
In our testing on a standard Odoo.sh instance (4 vCPU, 8 GB):
- Single-record creates: ~50–80 records/second
- Batch creates (500 records/call): ~2,000–5,000 records/second
- Search queries with 10-field read: ~200–400 records/second
The bottleneck is almost always Odoo’s Python ORM, not the network. To maximise throughput:
- Use batch operations (as above)
- Only read the fields you need (
fieldsparameter insearch_read) - Avoid computed fields in search results — request them separately if needed
- Use
sudo()on the Odoo side when permissions checks are adding overhead in trusted integrations
Conclusion
Odoo integrations done well are maintainable, auditable, and robust. The patterns above — proper authentication, idempotent upserts, batch operations, and structured error handling — are the difference between an integration that works in testing and one that stays stable in production for years.
Need help designing or fixing an Odoo integration? Contact AegeanFlows.
Kostas Polakis — Senior SAP ABAP Architect & Odoo Integration Specialist, Athens, Greece.