My Journey Building REST APIs in Odoo
I’ve built a lot of custom APIs in Odoo. Some were elegant, some were… let’s say “learning experiences.” Here’s everything I wish I knew before that first API project.
The API That Nearly Got Me Fired
My first Odoo API was for a client who needed to sync their e-commerce store with Odoo. How hard could it be?
Very hard, as it turns out.
Within a week, I had created:
- Zero authentication (oops)
- No rate limiting (they hammered my server)
- No error handling (when things broke, I had no idea why)
- No logging (debugging was… creative)
The client called at 11 PM because the API had crashed their production server. Not my finest moment.
That project taught me more than any documentation could. Here’s what I’ve learned since.
The Foundation: Clean Controller Structure
Every API controller I write now follows this pattern:
from odoo import http
from odoo.http import request
import logging
_logger = logging.getLogger(__name__)
class PartnerAPI(http.Controller):
@http.route('/api/v1/partners', type='json', auth='none', methods=['GET'], csrf=False)
def list_partners(self, **kwargs):
"""
List partners with pagination
Args:
page: Page number (default: 1)
limit: Items per page (max: 100)
Returns:
Dict with partners array and pagination info
"""
# Always validate auth first
user = self._authenticate()
if not user:
return {'error': 'Unauthorized'}, 401
# Log every request
_logger.info(f"API: {request.httprequest.method} {request.httprequest.path}")
# Parse and validate params
page = int(kwargs.get('page', 1))
limit = min(int(kwargs.get('limit', 20)), 100)
try:
partners = self._get_partners(page, limit)
return partners
except Exception as e:
_logger.exception("Error fetching partners")
return {'error': 'Internal error'}, 500
The key lessons here:
- Authenticate before doing anything
- Log everything - you’ll thank yourself later
- Always wrap in try/except
- Return proper HTTP status codes
Authentication That Actually Works
Here’s what I use in production:
class APIBase(http.Controller):
def _authenticate(self):
"""Validate API key from Authorization header"""
auth_header = request.httprequest.headers.get('Authorization', '')
if not auth_header.startswith('ApiKey '):
return None
api_key = auth_header[7:] # Remove 'ApiKey ' prefix
# Look up the key
key_record = request.env['api.key'].sudo().search([
('key', '=', api_key),
('active', '=', True)
], limit=1)
if not key_record:
_logger.warning(f"Invalid API key attempt from {request.httprequest.remote_addr}")
return None
# Update last used
key_record.sudo().write({'last_used': fields.Datetime.now()})
return key_record.user_id
And the model to store keys:
class APIKey(models.Model):
_name = 'api.key'
_description = 'API Keys'
name = fields.Char('Description', required=True)
key = fields.Char('API Key', required=True)
user_id = fields.Many2one('res.users', 'User', required=True)
active = fields.Boolean('Active', default=True)
last_used = fields.Datetime('Last Used')
_sql_constraints = [
('key_unique', 'unique(key)', 'API key must be unique!')
]
Pro tips:
- Store a hash of the key, not the key itself
- Add
last_usedso you can spot inactive keys - Give each integration its own key (easier to revoke)
Pagination That Doesn’t Crash
My first API returned ALL records. For a client with 50,000 partners. You can imagine.
Here’s the pattern I use now:
@http.route('/api/v1/partners', type='json', auth='none', csrf=False)
def list_partners(self, **kwargs):
user = self._authenticate()
if not user:
return {'error': 'Unauthorized'}, 401
# Parse params
page = max(1, int(kwargs.get('page', 1)))
limit = min(max(1, int(kwargs.get('limit', 20))), 100)
offset = (page - 1) * limit
# Get total count
domain = self._build_domain(kwargs)
total = request.env['res.partner'].search_count(domain)
# Get records
partners = request.env['res.partner'].search_read(
domain,
['id', 'name', 'email', 'phone'],
limit=limit,
offset=offset,
order='name'
)
return {
'data': partners,
'pagination': {
'page': page,
'limit': limit,
'total': total,
'pages': (total + limit - 1) // limit
}
}
Error Handling That Helps Debug
When something goes wrong (and it will), you need to know what happened:
class APIError(Exception):
"""Base API error"""
def __init__(self, message, status=400):
self.message = message
self.status = status
super().__init__(message)
class ValidationError(APIError):
def __init__(self, message):
super().__init__(message, 400)
class NotFoundError(APIError):
def __init__(self, message="Resource not found"):
super().__init__(message, 404)
class UnauthorizedError(APIError):
def __init__(self, message="Authentication required"):
super().__init__(message, 401)
And a decorator to handle them:
def api_handler(func):
"""Catch errors and return proper responses"""
def wrapper(self, *args, **kwargs):
try:
return func(self, *args, **kwargs)
except ValidationError as e:
return {'error': e.message, 'code': 'VALIDATION_ERROR'}, e.status
except NotFoundError as e:
return {'error': e.message, 'code': 'NOT_FOUND'}, e.status
except UnauthorizedError as e:
return {'error': e.message, 'code': 'UNAUTHORIZED'}, e.status
except Exception as e:
_logger.exception(f"Unexpected error in {func.__name__}")
return {'error': 'Internal server error', 'code': 'INTERNAL_ERROR'}, 500
return wrapper
Usage:
@http.route('/api/v1/partners', type='json', auth='none', csrf=False)
@api_handler
def create_partner(self, **kwargs):
if not kwargs.get('name'):
raise ValidationError("Name is required")
# ... create logic
Rate Limiting (Finally)
After that crashed server incident, I take rate limiting seriously:
from collections import defaultdict
import time
import threading
class RateLimiter:
"""Simple in-memory rate limiter"""
_instance = None
_lock = threading.Lock()
def __new__(cls):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._requests = defaultdict(list)
return cls._instance
def is_allowed(self, key, limit=100, window=60):
now = time.time()
# Clean old requests
self._requests[key] = [
t for t in self._requests[key]
if now - t < window
]
if len(self._requests[key]) >= limit:
return False, limit - len(self._requests[key])
self._requests[key].append(now)
return True, limit - len(self._requests[key]) - 1
@http.route('/api/v1/partners', type='json', auth='none', csrf=False)
def list_partners(self, **kwargs):
# Get client identifier (API key or IP)
auth = request.httprequest.headers.get('Authorization', '')
key = auth or request.httprequest.remote_addr
# Check rate limit
limiter = RateLimiter()
allowed, remaining = limiter.is_allowed(key, limit=50, window=60)
if not allowed:
return {
'error': 'Rate limit exceeded',
'retry_after': 60
}, 429
# ... rest of logic
Versioning from Day One
I always start with /api/v1/. Always. Even if I think I won’t need versions.
Trust me. You’ll need it.
@http.route('/api/v1/partners', type='json', auth='none', methods=['GET'])
def list_partners_v1(self, **kwargs):
"""Version 1 - basic partner data"""
partners = request.env['res.partner'].search_read(
[], ['name', 'email']
)
return {'data': partners}
@http.route('/api/v2/partners', type='json', auth='none', methods=['GET'])
def list_partners_v2(self, **kwargs):
"""Version 2 - extended data with addresses"""
partners = request.env['res.partner'].search_read(
[], ['name', 'email', 'phone', 'street', 'city', 'country_id']
)
return {'data': partners, 'version': '2.0'}
What I’d Do Different
If I could go back and tell my past self something:
- Start with authentication - not as an afterthought
- Add logging from the first endpoint - you’ll need it
- Version everything - /api/v1/ from day one
- Test with realistic data - 10 records tells you nothing
- Document as you build - future you will thank present you
The Real Truth
Building APIs in Odoo isn’t technically hard. Building APIs that don’t embarrass you in production is harder.
The things that actually matter:
- Authentication from the start (always)
- Pagination always (always)
- Good error handling (always)
- Logging everything (always)
- Versioning early (always)
What’s your API horror story? Share in the comments - we’ve all been there.