Why API Best Practices Matter
Custom Odoo APIs power mobile apps, SPAs, integrations, and webhooks. A poorly designed API causes: security vulnerabilities, performance issues, breaking changes, and integration nightmares. Following best practices from the start saves months of rework.
1. URL Versioning
# Version your API URLs:
# /api/v1/partners ← current version
# /api/v2/partners ← next version (breaking changes)
# In Odoo controller:
class ApiV1Controller(http.Controller):
@http.route('/api/v1/partners', type='json', auth='public')
def get_partners_v1(self, **kwargs):
# v1 implementation
pass
class ApiV2Controller(http.Controller):
@http.route('/api/v2/partners', type='json', auth='public')
def get_partners_v2(self, **kwargs):
# v2 implementation (different response format)
pass
# Rule: never break v1 after clients use it
# Deprecate gradually: announce → warn → sunset2. Consistent Response Format
# Always return the same structure:
{
"success": true,
"data": {"partners": [...]},
"meta": {"total": 150, "limit": 20, "offset": 0}
}
# Error response:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Email is required",
"details": {"field": "email"}
}
}
# Never mix formats — clients should know exactly what to parse3. Pagination
# Always paginate list endpoints:
@http.route('/api/v1/partners', type='json', auth='user')
def get_partners(self, limit=20, offset=0, **kwargs):
limit = min(int(limit), 100) # Cap at 100
offset = max(int(offset), 0)
domain = [('is_company', '=', True)]
total = request.env['res.partner'].search_count(domain)
partners = request.env['res.partner'].search(
domain, limit=limit, offset=offset, order='name'
)
return {
'success': True,
'data': [p._to_api_dict() for p in partners],
'meta': {'total': total, 'limit': limit, 'offset': offset}
}4. Input Validation
# Validate ALL input before processing:
def _validate_partner_data(self, data):
errors = []
if not data.get('name'):
errors.append({'field': 'name', 'message': 'Name is required'})
if data.get('email') and '@' not in data['email']:
errors.append({'field': 'email', 'message': 'Invalid email format'})
if data.get('phone') and len(data['phone']) < 7:
errors.append({'field': 'phone', 'message': 'Phone too short'})
return errors
@http.route('/api/v1/partners', type='json', auth='user', methods=['POST'])
def create_partner(self, **kwargs):
errors = self._validate_partner_data(kwargs)
if errors:
return {'success': False, 'error': {'code': 'VALIDATION_ERROR', 'details': errors}}
partner = request.env['res.partner'].create({
'name': kwargs['name'],
'email': kwargs.get('email'),
})
return {'success': True, 'data': partner._to_api_dict()}5. Rate Limiting
# Prevent abuse and protect server resources:
import time
from collections import defaultdict
_rate_limits = defaultdict(list) # {ip: [timestamps]}
def _check_rate_limit(self, ip, max_requests=60, window=60):
"""Allow max_requests per window seconds."""
now = time.time()
_rate_limits[ip] = [t for t in _rate_limits[ip] if now - t < window]
if len(_rate_limits[ip]) >= max_requests:
return False # Rate limited
_rate_limits[ip].append(now)
return True
@http.route('/api/v1/data', type='json', auth='public')
def get_data(self, **kwargs):
ip = request.httprequest.remote_addr
if not self._check_rate_limit(ip):
return {'success': False, 'error': {'code': 'RATE_LIMITED', 'message': 'Too many requests'}}6. Error Handling
# Catch and format all errors:
@http.route('/api/v1/partners/', type='json', auth='user')
def get_partner(self, partner_id):
try:
partner = request.env['res.partner'].browse(partner_id)
if not partner.exists():
return {'success': False, 'error': {'code': 'NOT_FOUND', 'message': 'Partner not found'}}
return {'success': True, 'data': partner._to_api_dict()}
except AccessError:
return {'success': False, 'error': {'code': 'FORBIDDEN', 'message': 'Access denied'}}
except Exception as e:
_logger.error("API error: %s", e, exc_info=True)
return {'success': False, 'error': {'code': 'INTERNAL_ERROR', 'message': 'An error occurred'}}7. Authentication
# Use API keys or JWT — not passwords:
# API Key in header:
# Authorization: Bearer api_key_here
def _authenticate_api_key(self):
auth_header = request.httprequest.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return None
api_key = auth_header[7:]
user = request.env['res.users'].sudo().search([
('api_key_ids.key', '=', api_key)
], limit=1)
return user or None8. Serialization
# Create a _to_api_dict() method on your models:
def _to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'name': self.name,
'email': self.email or None,
'phone': self.phone or None,
'created_at': self.create_date.isoformat() if self.create_date else None,
}
# Benefits:
# - Consistent output format
# - Control exactly which fields are exposed
# - Handle None/False conversion
# - Date formatting in one placeChecklist
- Version your URLs (/api/v1/...)
- Consistent response format (success, data, error, meta)
- Paginate all list endpoints (limit, offset, total)
- Validate all input before processing
- Rate limit public endpoints
- Catch and format all errors (never expose tracebacks)
- Use API keys or JWT (not passwords)
- Create _to_api_dict() for serialization
- Log all API calls for debugging
- Document your API (OpenAPI/Swagger)
DeployMonkey
DeployMonkey's API follows all these best practices — versioned endpoints, JWT authentication, rate limiting, input validation, and consistent error handling. The AI agent helps you build custom APIs for your Odoo modules with the same standards.