Odoo REST API: Practical Examples and Integration Tutorial
According to Postman's 2025 State of APIs report, 73% of businesses now integrate their ERP with at least three external systems. Odoo, powering over 12 million users worldwide, exposes its entire data model through a rich API layer. Yet the documentation leaves many developers struggling with authentication flows, batch operations, and production-grade error handling.
This tutorial provides copy-paste-ready examples in both Python and Node.js for every common integration pattern. Whether you are syncing Shopify orders, pushing data from a mobile app, or building a custom dashboard, this guide has you covered.
Key Takeaways
- Odoo offers three API access methods: XML-RPC (legacy, all versions), JSON-RPC (web client protocol), and REST API (Odoo 17+ with API keys) — each with different authentication and use cases.
- API key authentication (Odoo 17+) is the recommended approach for server-to-server integrations — no session management, no CSRF tokens, straightforward HTTP headers.
- Search domains use Odoo's powerful Polish notation for filtering — master the operators and you can query any data combination.
- Batch operations are critical for performance — creating 1,000 records with one API call is 50x faster than 1,000 individual calls.
- Error handling and rate limiting are essential for production integrations — Odoo returns structured error responses that your code should parse and handle gracefully.
1. Authentication Methods
Method 1: API Keys (Recommended for Odoo 17+)
API keys are the simplest and most secure method for server-to-server communication:
# Generate an API key in Odoo:
# Settings → Users → Select user → Account Security → New API Key
# Test authentication
curl -X GET "https://your-odoo.com/api/res.partner" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json"
Python with API Key:
import requests
class OdooAPI:
def __init__(self, url, api_key):
self.url = url.rstrip('/')
self.session = requests.Session()
self.session.headers.update({
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
})
def get(self, model, params=None):
response = self.session.get(
f'{self.url}/api/{model}',
params=params or {}
)
response.raise_for_status()
return response.json()
def post(self, model, data):
response = self.session.post(
f'{self.url}/api/{model}',
json=data
)
response.raise_for_status()
return response.json()
# Usage
odoo = OdooAPI('https://your-odoo.com', 'your-api-key-here')
partners = odoo.get('res.partner', {'limit': 10})
Node.js with API Key:
const axios = require('axios');
class OdooAPI {
constructor(url, apiKey) {
this.client = axios.create({
baseURL: `${url}/api`,
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
timeout: 30000,
});
}
async get(model, params = {}) {
const { data } = await this.client.get(`/${model}`, { params });
return data;
}
async post(model, body) {
const { data } = await this.client.post(`/${model}`, body);
return data;
}
async put(model, id, body) {
const { data } = await this.client.put(`/${model}/${id}`, body);
return data;
}
async delete(model, id) {
const { data } = await this.client.delete(`/${model}/${id}`);
return data;
}
}
// Usage
const odoo = new OdooAPI('https://your-odoo.com', 'your-api-key');
const partners = await odoo.get('res.partner', { limit: 10 });
Method 2: JSON-RPC (All Versions)
JSON-RPC is the protocol used by Odoo's web client internally. It works with all Odoo versions:
import requests
import json
class OdooJsonRpc:
def __init__(self, url, db, username, password):
self.url = url.rstrip('/')
self.db = db
self.session = requests.Session()
self.uid = self._authenticate(username, password)
def _authenticate(self, username, password):
response = self._call('/web/session/authenticate', {
'db': self.db,
'login': username,
'password': password,
})
if not response.get('uid'):
raise Exception(f"Authentication failed: {response}")
return response['uid']
def _call(self, endpoint, params):
payload = {
'jsonrpc': '2.0',
'method': 'call',
'params': params,
'id': None,
}
response = self.session.post(
f'{self.url}{endpoint}',
json=payload,
headers={'Content-Type': 'application/json'}
)
result = response.json()
if result.get('error'):
raise Exception(result['error']['data']['message'])
return result.get('result')
def search_read(self, model, domain=None, fields=None, limit=80, offset=0, order=None):
return self._call('/web/dataset/call_kw', {
'model': model,
'method': 'search_read',
'args': [domain or []],
'kwargs': {
'fields': fields or [],
'limit': limit,
'offset': offset,
'order': order or '',
},
})
def create(self, model, values):
return self._call('/web/dataset/call_kw', {
'model': model,
'method': 'create',
'args': [values],
'kwargs': {},
})
def write(self, model, ids, values):
return self._call('/web/dataset/call_kw', {
'model': model,
'method': 'write',
'args': [ids, values],
'kwargs': {},
})
# Usage
odoo = OdooJsonRpc('https://your-odoo.com', 'mydb', 'admin', 'password')
orders = odoo.search_read('sale.order', [('state', '=', 'sale')],
fields=['name', 'amount_total', 'partner_id'],
limit=20)
Method 3: XML-RPC (Legacy, Universal)
import xmlrpc.client
url = 'https://your-odoo.com'
db = 'mydb'
username = 'admin'
password = 'password'
# Authenticate
common = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/common')
uid = common.authenticate(db, username, password, {})
# Create models proxy
models = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/object')
# Search and read
partners = models.execute_kw(db, uid, password,
'res.partner', 'search_read',
[[('is_company', '=', True), ('country_id.code', '=', 'US')]],
{'fields': ['name', 'email', 'phone'], 'limit': 10}
)
2. CRUD Operations
Create Records
# Create a single contact
partner_id = odoo.create('res.partner', {
'name': 'Acme Corporation',
'is_company': True,
'email': '[email protected]',
'phone': '+1-555-0123',
'street': '123 Main Street',
'city': 'San Francisco',
'state_id': 5, # California
'country_id': 233, # United States
'category_id': [(6, 0, [1, 3])], # Tags: replace all with IDs 1 and 3
})
# Create a sale order with lines
order_id = odoo.create('sale.order', {
'partner_id': partner_id,
'date_order': '2026-03-23',
'order_line': [
(0, 0, {
'product_id': 42,
'product_uom_qty': 5,
'price_unit': 99.99,
}),
(0, 0, {
'product_id': 43,
'product_uom_qty': 2,
'price_unit': 149.99,
}),
],
})
Node.js equivalent:
// Create a contact
const partnerId = await odoo.post('res.partner', {
name: 'Acme Corporation',
is_company: true,
email: '[email protected]',
phone: '+1-555-0123',
street: '123 Main Street',
city: 'San Francisco',
country_id: 233,
});
// Create sale order with lines
const orderId = await odoo.post('sale.order', {
partner_id: partnerId,
date_order: '2026-03-23',
order_line: [
[0, 0, { product_id: 42, product_uom_qty: 5, price_unit: 99.99 }],
[0, 0, { product_id: 43, product_uom_qty: 2, price_unit: 149.99 }],
],
});
Read Records
# Read specific fields from specific records
data = odoo.search_read('sale.order',
domain=[('state', '=', 'sale'), ('amount_total', '>', 500)],
fields=['name', 'partner_id', 'amount_total', 'date_order', 'state'],
limit=50,
offset=0,
order='date_order desc'
)
# Read a single record by ID (REST API)
# GET /api/sale.order/42?fields=name,amount_total
// Node.js — read with pagination
async function fetchAllOrders(odoo) {
const pageSize = 100;
let offset = 0;
let allOrders = [];
while (true) {
const orders = await odoo.get('sale.order', {
domain: JSON.stringify([['state', '=', 'sale']]),
fields: 'name,partner_id,amount_total',
limit: pageSize,
offset,
order: 'date_order desc',
});
allOrders = allOrders.concat(orders);
if (orders.length < pageSize) break;
offset += pageSize;
}
return allOrders;
}
Update Records
# Update a single record
odoo.write('res.partner', [partner_id], {
'phone': '+1-555-9999',
'website': 'https://acme.com',
})
# Update multiple records at once
draft_orders = odoo.search_read('sale.order',
[('state', '=', 'draft'), ('date_order', '<', '2026-01-01')],
fields=['id']
)
ids = [o['id'] for o in draft_orders]
odoo.write('sale.order', ids, {'note': 'Reviewed Q1 2026'})
Delete Records
# Delete records (use with caution)
odoo.write('res.partner', [obsolete_id], {'active': False}) # Prefer archiving
# Actually delete (rarely needed)
# models.execute_kw(db, uid, password, 'res.partner', 'unlink', [[obsolete_id]])
3. Advanced Search Domains
Odoo's search domains use Polish notation (prefix notation) to combine conditions. The default operator between conditions is AND. Use pipe '|' for OR and ampersand '&' for explicit AND. Each leaf is a tuple of field name, operator, and value. Odoo supports dot notation to filter on related model fields, such as 'partner_id.country_id.code' to filter orders by the customer's country.
# Complex domain examples
# Orders from US customers with amount > $1000, created this year
domain = [
('partner_id.country_id.code', '=', 'US'),
('amount_total', '>', 1000),
('date_order', '>=', '2026-01-01'),
('state', 'in', ['sale', 'done']),
]
# OR condition: email contains 'gmail' OR 'yahoo'
domain = [
'|',
('email', 'ilike', 'gmail.com'),
('email', 'ilike', 'yahoo.com'),
]
# Complex: (state=sale AND amount>1000) OR (state=done AND amount>5000)
domain = [
'|',
'&', ('state', '=', 'sale'), ('amount_total', '>', 1000),
'&', ('state', '=', 'done'), ('amount_total', '>', 5000),
]
# Negation: NOT archived
domain = [('active', '!=', False)]
# NULL check: has no email
domain = [('email', '=', False)]
# Hierarchical: all child categories of 'Electronics'
domain = [('categ_id', 'child_of', electronics_id)]
# Date ranges
from datetime import datetime, timedelta
domain = [
('create_date', '>=', (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')),
('create_date', '<', datetime.now().strftime('%Y-%m-%d')),
]
Operator Reference
| Operator | Description | Example |
|---|---|---|
= | Exact match | ('state', '=', 'sale') |
!= | Not equal | ('state', '!=', 'cancelled') |
>, >=, <, <= | Comparison | ('amount', '>', 1000) |
in | Value in list | ('state', 'in', ['sale', 'done']) |
not in | Value not in list | ('state', 'not in', ['cancel']) |
like | SQL LIKE (case-sensitive) | ('name', 'like', 'Acme%') |
ilike | Case-insensitive LIKE | ('email', 'ilike', '%@gmail.com') |
=like | Pattern match | ('ref', '=like', 'SO%') |
child_of | Hierarchical descendants | ('categ_id', 'child_of', 1) |
parent_of | Hierarchical ancestors | ('categ_id', 'parent_of', 5) |
4. Batch Operations
Batch operations are essential for performance. Never create records one at a time in a loop:
# BAD: 1000 API calls (slow, ~300 seconds)
for customer in customers:
odoo.create('res.partner', customer)
# GOOD: 1 API call with batch (fast, ~3 seconds)
# Using JSON-RPC batch create
partner_ids = odoo._call('/web/dataset/call_kw', {
'model': 'res.partner',
'method': 'create',
'args': [customers], # Pass list of dicts
'kwargs': {},
})
// Node.js batch pattern with chunking
async function batchCreate(odoo, model, records, chunkSize = 200) {
const results = [];
for (let i = 0; i < records.length; i += chunkSize) {
const chunk = records.slice(i, i + chunkSize);
console.log(`Creating ${model} batch ${i / chunkSize + 1}/${Math.ceil(records.length / chunkSize)}`);
const ids = await odoo.post(model, chunk);
results.push(...(Array.isArray(ids) ? ids : [ids]));
// Respect rate limits
if (i + chunkSize < records.length) {
await new Promise(resolve => setTimeout(resolve, 500));
}
}
return results;
}
// Usage
const customers = generateCustomerData(); // Array of 5000 objects
const ids = await batchCreate(odoo, 'res.partner', customers);
console.log(`Created ${ids.length} partners`);
5. Error Handling
Production integrations must handle errors gracefully:
import requests
import logging
import time
logger = logging.getLogger(__name__)
class OdooAPIClient:
MAX_RETRIES = 3
RETRY_DELAY = 2 # seconds
def __init__(self, url, api_key):
self.url = url.rstrip('/')
self.session = requests.Session()
self.session.headers.update({
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
})
def _request(self, method, endpoint, **kwargs):
last_error = None
for attempt in range(self.MAX_RETRIES):
try:
response = self.session.request(
method,
f'{self.url}{endpoint}',
timeout=30,
**kwargs
)
if response.status_code == 429:
retry_after = int(response.headers.get('Retry-After', 60))
logger.warning(f"Rate limited. Retrying after {retry_after}s")
time.sleep(retry_after)
continue
if response.status_code == 401:
raise AuthenticationError("Invalid API key or session expired")
if response.status_code == 403:
raise PermissionError(f"Access denied: {response.text}")
if response.status_code == 404:
raise RecordNotFoundError(f"Record not found: {endpoint}")
if response.status_code >= 500:
logger.error(f"Server error {response.status_code}: {response.text}")
if attempt < self.MAX_RETRIES - 1:
time.sleep(self.RETRY_DELAY * (attempt + 1))
continue
raise ServerError(f"Server error after {self.MAX_RETRIES} retries")
response.raise_for_status()
return response.json()
except requests.exceptions.ConnectionError as e:
last_error = e
logger.warning(f"Connection error (attempt {attempt + 1}): {e}")
if attempt < self.MAX_RETRIES - 1:
time.sleep(self.RETRY_DELAY * (attempt + 1))
except requests.exceptions.Timeout as e:
last_error = e
logger.warning(f"Request timeout (attempt {attempt + 1}): {e}")
if attempt < self.MAX_RETRIES - 1:
time.sleep(self.RETRY_DELAY * (attempt + 1))
raise ConnectionError(f"Failed after {self.MAX_RETRIES} attempts: {last_error}")
class AuthenticationError(Exception): pass
class PermissionError(Exception): pass
class RecordNotFoundError(Exception): pass
class ServerError(Exception): pass
// Node.js error handling with axios interceptors
const axios = require('axios');
function createOdooClient(url, apiKey) {
const client = axios.create({
baseURL: `${url}/api`,
headers: { Authorization: `Bearer ${apiKey}` },
timeout: 30000,
});
// Response interceptor for error handling
client.interceptors.response.use(
(response) => response,
async (error) => {
const { config, response } = error;
config.__retryCount = config.__retryCount || 0;
// Rate limiting
if (response?.status === 429) {
const retryAfter = parseInt(response.headers['retry-after'] || '60', 10);
console.warn(`Rate limited. Waiting ${retryAfter}s...`);
await new Promise((r) => setTimeout(r, retryAfter * 1000));
return client(config);
}
// Retry on server errors (max 3)
if (response?.status >= 500 && config.__retryCount < 3) {
config.__retryCount += 1;
const delay = config.__retryCount * 2000;
console.warn(`Server error ${response.status}. Retry ${config.__retryCount}/3 in ${delay}ms`);
await new Promise((r) => setTimeout(r, delay));
return client(config);
}
// Structured error response
const errorMessage = response?.data?.error?.message
|| response?.data?.message
|| error.message;
throw new Error(`Odoo API Error [${response?.status}]: ${errorMessage}`);
}
);
return client;
}
6. Real-World Integration Examples
Shopify to Odoo Order Sync
class ShopifyOdooSync:
def __init__(self, odoo_client, shopify_client):
self.odoo = odoo_client
self.shopify = shopify_client
def sync_order(self, shopify_order):
# 1. Find or create customer
partner = self._get_or_create_partner(shopify_order['customer'])
# 2. Map products
order_lines = []
for item in shopify_order['line_items']:
product_id = self._find_product_by_sku(item['sku'])
if not product_id:
logger.warning(f"Product not found for SKU: {item['sku']}")
continue
order_lines.append((0, 0, {
'product_id': product_id,
'product_uom_qty': item['quantity'],
'price_unit': float(item['price']),
'discount': self._calc_discount(item),
}))
# 3. Create sale order
order_id = self.odoo.create('sale.order', {
'partner_id': partner['id'],
'client_order_ref': shopify_order['name'], # Shopify order #
'order_line': order_lines,
'note': f"Shopify Order: {shopify_order['id']}",
})
# 4. Auto-confirm if paid
if shopify_order['financial_status'] == 'paid':
self.odoo._call('/web/dataset/call_kw', {
'model': 'sale.order',
'method': 'action_confirm',
'args': [[order_id]],
'kwargs': {},
})
return order_id
def _get_or_create_partner(self, customer):
# Search by email first
existing = self.odoo.search_read('res.partner',
[('email', '=', customer['email'])],
fields=['id', 'name'], limit=1)
if existing:
return existing[0]
return {'id': self.odoo.create('res.partner', {
'name': f"{customer['first_name']} {customer['last_name']}",
'email': customer['email'],
'phone': customer.get('phone'),
})}
def _find_product_by_sku(self, sku):
products = self.odoo.search_read('product.product',
[('default_code', '=', sku)],
fields=['id'], limit=1)
return products[0]['id'] if products else None
Webhook Receiver (Flask Example)
from flask import Flask, request, jsonify
import hmac
import hashlib
app = Flask(__name__)
@app.route('/webhook/odoo/order', methods=['POST'])
def handle_odoo_webhook():
# Verify signature
signature = request.headers.get('X-Odoo-Signature')
expected = hmac.new(
WEBHOOK_SECRET.encode(),
request.data,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
return jsonify({'error': 'Invalid signature'}), 401
payload = request.json
event_type = payload.get('event')
if event_type == 'sale.order.confirmed':
handle_order_confirmed(payload['data'])
elif event_type == 'stock.picking.done':
handle_shipment_complete(payload['data'])
return jsonify({'status': 'ok'}), 200
7. Performance Tips
| Tip | Impact | Details |
|---|---|---|
Use fields parameter | High | Only request needed fields — reduces payload 5-10x |
| Batch creates | High | 1 call with 500 records vs 500 calls — 50x faster |
| Paginate large datasets | Medium | Use limit and offset — avoid loading 100K records |
| Cache read-only data | Medium | Cache product catalogs, categories (TTL 5-15 min) |
Use search_count | Low | Count before fetching — avoid loading data just to count |
| Connection pooling | Medium | Reuse HTTP sessions — saves TLS handshake overhead |
Frequently Asked Questions
What is the difference between XML-RPC, JSON-RPC, and REST API in Odoo?
XML-RPC is the legacy protocol available in all Odoo versions — it is verbose but universally supported. JSON-RPC is the protocol used by Odoo's web client and provides the same functionality with JSON payloads. The REST API was introduced in Odoo 17 and provides standard HTTP endpoints with API key authentication, making it the easiest option for modern integrations. For new projects, use the REST API if you are on Odoo 17 or later.
How do I handle Odoo API rate limits?
Odoo.sh applies rate limits based on your plan tier. When you receive a 429 response, read the Retry-After header and wait before retrying. For high-volume integrations, implement exponential backoff, batch your operations to reduce the number of API calls, and consider using Odoo's scheduled actions for bulk processing instead of real-time API calls for non-critical syncs.
Can I call custom Python methods through the API?
Yes. Any public method on an Odoo model can be called through XML-RPC or JSON-RPC using execute_kw. For the REST API, you need to create a custom controller endpoint with @http.route. Methods starting with underscore are private and cannot be called externally through XML-RPC. Always validate inputs in your custom methods to prevent injection attacks.
How do I sync large datasets efficiently?
Use a combination of strategies: initial full sync with batch operations and pagination (limit 200 records per request), then incremental syncs using write_date filtering to only fetch records modified since the last sync. Store the last sync timestamp and use it as a domain filter. For very large datasets exceeding 100,000 records, consider direct database replication instead of API synchronization.
Is the Odoo REST API available in Odoo Community Edition?
The native REST API with API key authentication was introduced in Odoo 17 Enterprise. For Odoo Community, you can use XML-RPC or JSON-RPC which are available in all editions, or install community modules like OCA's rest-framework that add RESTful endpoints. ECOSIRE's integration services support all Odoo editions and API protocols.
How do I handle Many2many and One2many fields in API calls?
Relational fields use special command tuples: (0, 0, values) to create and link a new record, (1, id, values) to update a linked record, (2, id, 0) to delete a linked record, (3, id, 0) to unlink without deleting, (4, id, 0) to link an existing record, (5, 0, 0) to unlink all, and (6, 0, [ids]) to replace all links. For reading, these fields return lists of IDs by default — use search_read with the field name to get full data.
Next Steps
API integration is the backbone of modern business systems. Whether you are building a simple data sync or a complex multi-platform orchestration, the patterns in this guide will serve you well.
Related resources:
- Odoo Python Development Guide — Deep dive into Odoo's Python backend
- Webhook Debugging Guide — Troubleshooting webhook integrations
- ECOSIRE Marketplace Connectors — Pre-built integrations for major platforms
Need help with Odoo API integration? ECOSIRE's integration team has connected Odoo to over 50 external platforms including Shopify, Amazon, Salesforce, and custom ERPs. From simple data syncs to real-time bidirectional orchestration, we build integrations that scale. Schedule a free technical consultation.
Written by
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
Related Articles
AI-Powered Customer Segmentation: From RFM to Predictive Clustering
Learn how AI transforms customer segmentation from static RFM analysis to dynamic predictive clustering. Implementation guide with Python, Odoo, and real ROI data.
AI for Supply Chain Optimization: Visibility, Prediction & Automation
Transform supply chain operations with AI: demand sensing, supplier risk scoring, route optimization, warehouse automation, and disruption prediction. 2026 guide.
API Integration Patterns: Enterprise Architecture Best Practices
Master API integration patterns for enterprise systems. REST vs GraphQL vs gRPC, event-driven architecture, saga pattern, API gateway, and versioning guide.