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:

  1. Treat the external API as source of truth
  2. Handle errors gracefully with retries
  3. Process everything in the background
  4. Design for idempotency
  5. 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.