What Are Controllers?
Controllers handle HTTP requests in Odoo. They define custom API endpoints, web pages, webhooks, and file download routes. Every URL your Odoo instance responds to (beyond the default web client) is handled by a controller.
Basic Controller
from odoo import http
from odoo.http import request
class MyController(http.Controller):
@http.route('/api/hello', type='json', auth='public', methods=['POST'])
def hello(self, **kwargs):
name = kwargs.get('name', 'World')
return {'message': f'Hello, {name}!'}
@http.route('/my/page', type='http', auth='public', website=True)
def my_page(self, **kwargs):
return request.render('my_module.my_template', {
'title': 'My Page',
})Route Parameters
| Parameter | Values | Default | Description |
|---|---|---|---|
type | 'http', 'json' | 'http' | Response format |
auth | 'user', 'public', 'none' | 'user' | Authentication required |
methods | ['GET'], ['POST'], etc. | ['GET','POST'] | Allowed HTTP methods |
website | True/False | False | Website page (adds layout) |
csrf | True/False | True | CSRF protection |
cors | '*' or origin | None | CORS header |
sitemap | True/False/func | True | Include in sitemap |
Authentication Modes
# auth='user' — Requires logged-in Odoo user
@http.route('/api/orders', type='json', auth='user')
def get_orders(self):
orders = request.env['sale.order'].search([])
return [{'name': o.name, 'total': o.amount_total} for o in orders]
# auth='public' — Anyone can access (logged-in gets user env, anonymous gets public user)
@http.route('/api/products', type='json', auth='public')
def get_products(self):
products = request.env['product.product'].sudo().search(
[('sale_ok', '=', True)], limit=50
)
return [{'name': p.name, 'price': p.list_price} for p in products]
# auth='none' — No authentication at all (no request.env available)
@http.route('/api/health', type='http', auth='none')
def health(self):
return 'OK'type='json' vs type='http'
# type='json' — Request body is JSON, response is JSON
# Content-Type: application/json
# Request: {"jsonrpc": "2.0", "params": {"name": "Alice"}}
# Response: {"jsonrpc": "2.0", "result": {"message": "Hello!"}}
@http.route('/api/data', type='json', auth='public')
def get_data(self, **kwargs):
return {'key': 'value'} # Auto-wrapped in JSON-RPC response
# type='http' — Standard HTTP request/response
# Can return HTML, redirect, file download, or JSON manually
@http.route('/api/data', type='http', auth='public')
def get_data(self, **kwargs):
data = json.dumps({'key': 'value'})
return request.make_response(data, headers=[
('Content-Type', 'application/json'),
])URL Parameters
# Path parameters
@http.route('/api/partners/<int:partner_id>', type='json', auth='user')
def get_partner(self, partner_id):
partner = request.env['res.partner'].browse(partner_id)
if not partner.exists():
return {'error': 'Not found'}
return {'name': partner.name, 'email': partner.email}
# String parameter
@http.route('/api/partners/<string:slug>', type='http', auth='public')
def get_partner_by_slug(self, slug):
# ...
# Query parameters (from URL ?key=value)
@http.route('/api/search', type='http', auth='public')
def search(self, q='', limit=10, **kwargs):
# q and limit come from ?q=test&limit=20
results = request.env['product.product'].sudo().search(
[('name', 'ilike', q)], limit=int(limit)
)File Upload
@http.route('/api/upload', type='http', auth='user', methods=['POST'], csrf=False)
def upload_file(self, file=None, **kwargs):
if not file:
return request.make_response(
json.dumps({'error': 'No file'}), status=400
)
# Read file content
content = file.read()
filename = file.filename
# Create attachment
attachment = request.env['ir.attachment'].create({
'name': filename,
'datas': base64.b64encode(content),
'type': 'binary',
})
return request.make_response(
json.dumps({'id': attachment.id, 'name': filename}),
headers=[('Content-Type', 'application/json')]
)File Download
@http.route('/api/download/<int:attachment_id>', type='http', auth='user')
def download_file(self, attachment_id):
attachment = request.env['ir.attachment'].browse(attachment_id)
if not attachment.exists():
return request.not_found()
content = base64.b64decode(attachment.datas)
return request.make_response(content, headers=[
('Content-Type', attachment.mimetype or 'application/octet-stream'),
('Content-Disposition', f'attachment; filename="{attachment.name}"'),
])Error Handling
from odoo.exceptions import AccessError, ValidationError
from werkzeug.exceptions import NotFound, Forbidden
@http.route('/api/orders/<int:order_id>', type='json', auth='user')
def get_order(self, order_id):
try:
order = request.env['sale.order'].browse(order_id)
if not order.exists():
return {'error': 'Order not found', 'code': 404}
return {'name': order.name, 'total': order.amount_total}
except AccessError:
return {'error': 'Access denied', 'code': 403}
except Exception as e:
return {'error': str(e), 'code': 500}CORS for External APIs
# Allow cross-origin requests (for SPA/mobile apps)
@http.route('/api/public/products', type='http', auth='none',
cors='*', methods=['GET', 'OPTIONS'])
def public_products(self):
# Handle preflight OPTIONS request
products = request.env['product.product'].sudo().search(
[('sale_ok', '=', True)], limit=20
)
data = json.dumps([{'name': p.name} for p in products])
return request.make_response(data, headers=[
('Content-Type', 'application/json'),
])Common Mistakes
- Forgetting csrf=False for external APIs — POST requests from external clients will fail without it
- Using sudo() carelessly — Bypasses all security; only use for public data endpoints
- Not handling auth='public' correctly — Anonymous users get the public user's environment, which has very limited access
- Returning HTML from type='json' — JSON routes must return JSON-serializable data
- Not catching exceptions — Unhandled errors show Odoo tracebacks to API consumers