Odoo Integration Patterns: Building Enterprise Connectors That Scale
Building integrations between Odoo and external systems is both an art and a science. Over the past year, I’ve built several enterprise Odoo integrations, including a voice AI platform that syncs contacts, campaigns, and call data. Here’s what I’ve learned.
The Challenge
Odoo is a powerful ERP system, but enterprises rarely use just Odoo. They have CRMs, marketing platforms, payment systems, and custom services that all need to talk to each other.
The challenge is building these integrations in a way that:
- Handles failures gracefully
- Doesn’t block users during syncs
- Maintains data consistency across systems
- Can scale as data volume grows
Pattern 1: API-First Architecture
The most important pattern: treat the external API as the source of truth.
# Bad: Store everything locally in Odoo
class MyContact(models.Model):
name = fields.Char()
phone = fields.Char()
external_id = fields.Char() # Just for reference
def sync_from_api(self):
data = api.get_contact(self.external_id)
self.name = data['name']
self.phone = data['phone']
This creates drift. Local data gets stale. Users don’t know which data is current.
# Good: API-first design
class MyContact(models.Model):
_name = 'my.contact'
external_id = fields.Char(required=True)
@property
def name(self):
return self._get_api_data()['name']
@api.model
def search_contacts(self, query):
# Search directly from API
return self._api_client.search(query)
With API-first, the external system is always the source of truth. Odoo becomes a view layer.
Pattern 2: Structured Error Handling
External APIs fail. Networks timeout. Auth tokens expire. Your integration needs to handle all of this gracefully.
class ApiError(Exception):
def __init__(self, status_code, message, retryable=False):
self.status_code = status_code
self.message = message
self.retryable = retryable
class ApiClient:
def _request(self, method, endpoint, **kwargs):
try:
response = requests.request(method, endpoint, **kwargs)
if response.status_code == 401:
raise ApiError(401, "Authentication failed", retryable=False)
elif response.status_code == 429:
raise ApiError(429, "Rate limited", retryable=True)
elif response.status_code >= 500:
raise ApiError(response.status_code, "Server error", retryable=True)
return response.json()
except requests.exceptions.Timeout:
raise ApiError(0, "Request timeout", retryable=True)
except requests.exceptions.ConnectionError:
raise ApiError(0, "Connection failed", retryable=True)
Then in your Odoo models:
def action_sync(self):
try:
self._do_sync()
self.message_post(body="Sync completed successfully")
except ApiError as e:
if e.retryable:
self._schedule_retry()
raise UserError(f"Sync failed, will retry: {e.message}")
else:
raise UserError(f"Sync failed: {e.message}")
Pattern 3: Background Processing
Never make users wait for API calls. Use Odoo’s queue system:
class ContactSync(models.Model):
_name = 'contact.sync'
contact_id = fields.Many2one('my.contact')
status = fields.Selection([
('pending', 'Pending'),
('processing', 'Processing'),
('done', 'Done'),
('error', 'Error')
])
error_message = fields.Text()
@api.model
def _cron_process_sync(self):
pending = self.search([('status', '=', 'pending')], limit=50)
for record in pending:
record.status = 'processing'
try:
record._execute_sync()
record.status = 'done'
except Exception as e:
record.status = 'error'
record.error_message = str(e)
This pattern gives you:
- Non-blocking user experience
- Built-in retry mechanism
- Audit trail of all sync operations
- Easy debugging of failures
Pattern 4: Webhook Handlers
Instead of polling for changes, let the external system tell you when something happens:
class WebhookController(http.Controller):
@http.route('/api/webhook', type='json', auth='public', csrf=False)
def handle_webhook(self):
# Verify webhook signature
signature = request.httprequest.headers.get('X-Webhook-Signature')
if not self._verify_signature(signature, request.jsonrequest):
return {'error': 'Invalid signature'}, 401
event_type = request.jsonrequest.get('event_type')
data = request.jsonrequest.get('data')
# Process in background
self.env['webhook.event'].sudo().create({
'event_type': event_type,
'payload': json.dumps(data),
'status': 'pending'
})
return {'status': 'received'}
Always:
- Verify webhook signatures
- Process asynchronously
- Return immediately (webhooks often have timeout limits)
- Store raw payloads for debugging
Pattern 5: Idempotent Operations
The same operation might run multiple times (retries, duplicate webhooks). Design for idempotency:
def sync_contact(self, external_id, data):
# Find or create by external_id
contact = self.search([('external_id', '=', external_id)])
if contact:
# Update only if data changed
if contact.data_hash != self._hash(data):
contact.write(self._map_data(data))
else:
# Create new
self.create({
'external_id': external_id,
**self._map_data(data)
})
Common Pitfalls
1. Storing API credentials in the database without encryption
# Bad
api_key = fields.Char(string="API Key")
# Good
api_key = fields.Char(string="API Key", groups="base.group_system")
# Even better: use Odoo's password field type or external secrets management
2. Not handling pagination
# Bad: Only gets first page
def get_contacts(self):
return api.get('/contacts')
# Good: Handles all pages
def get_contacts(self):
results = []
page = 1
while True:
response = api.get('/contacts', params={'page': page})
results.extend(response['data'])
if not response.get('next_page'):
break
page += 1
return results
3. Blocking the UI during long operations
Use with_delay() or background jobs for anything that might take more than a second.
Testing Strategies
Integration tests for Odoo connectors are critical:
class TestApiIntegration(TransactionCase):
def setUp(self):
super().setUp()
# Mock the API client
self.mock_client = Mock()
self.env['my.contact']._api_client = self.mock_client
def test_sync_creates_contact(self):
self.mock_client.get_contact.return_value = {
'id': '123',
'name': 'Test Contact',
'phone': '+1234567890'
}
contact = self.env['my.contact'].sync_contact('123')
self.assertEqual(contact.name, 'Test Contact')
self.mock_client.get_contact.assert_called_once_with('123')
Wrapping Up
Building Odoo integrations is rewarding work. You’re connecting disparate systems and enabling workflows that weren’t possible before. The key is to:
- Treat the external API as source of truth
- Handle errors gracefully with retries
- Process everything in the background
- Design for idempotency
- Test thoroughly with mocks
The patterns I’ve described here have saved me countless hours of debugging and late-night incidents. I hope they help you build robust integrations too.
Building an Odoo integration? I’d love to hear about your challenges and solutions.