What a Controller Is#
Odoo runs on top of werkzeug, a Python WSGI framework. A controller is a Python class that inherits from odoo.http.Controller and exposes methods decorated with @http.route. When a matching URL is requested, Odoo dispatches the call to the decorated method.
Controllers live in the controllers/ directory of a module. They are loaded automatically when the module is installed.
Basic Route Definition#
from odoo import http
from odoo.http import request
class MyController(http.Controller):
@http.route('/my-module/hello', type='http', auth='public', website=True)
def hello(self, **kwargs):
return request.render('my_module.hello_template', {
'message': 'Hello from Odoo',
})Key parameters of @http.route:
- route: the URL path (string or list of strings); supports
andpath converters - type:
'http'for HTML/plain responses,'json'for JSON-RPC (automatically parses request body and serializes response) - auth: authentication requirement (see below)
- methods: list of allowed HTTP methods, e.g.
['GET', 'POST'] - website: if
True, injects website context (theme, multi-language support, etc.) - csrf: CSRF token checking; defaults to
Truefortype='http'POST routes
Authentication Modes#
| auth value | Who can access |
|---|---|
'user' | Logged-in internal users only (default for backend routes) |
'public' | Any visitor; session user is the public user if not logged in |
'none' | No session at all - use for webhooks and external API endpoints |
With auth='public', request.env.user is the public user (no access rights). Use request.env(user=request.env.ref('base.user_admin').id) or sudo() to elevate privileges when needed, with care.
With auth='none', request.env is not available. You must build a database cursor manually - avoid this unless building low-level webhooks.
JSON Routes#
Use type='json' for API endpoints:
@http.route('/api/my-module/data', type='json', auth='user', methods=['POST'])
def get_data(self, record_id, **kwargs):
record = request.env['my.model'].browse(record_id)
return {
'id': record.id,
'name': record.name,
'state': record.state,
}With type='json':
- The request body is parsed as JSON-RPC 2.0 (Odoo's convention)
- The return value is automatically serialized as JSON
- Exceptions are caught and returned as JSON error responses
- CSRF is not enforced (JSON routes are typically called programmatically)
Call it from JavaScript:
const result = await this.orm.call("my.model", "some_method", [recordId]);
// or via fetch for raw JSON-RPC:
fetch('/api/my-module/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: { record_id: 42 } }),
});Accessing the ORM#
Inside a controller method, use request.env to access the ORM:
@http.route('/api/orders/<int:order_id>', type='json', auth='user')
def get_order(self, order_id):
order = request.env['sale.order'].browse(order_id)
if not order.exists():
return {'error': 'not found'}
# request.env respects the current user's access rights
return {'name': order.name, 'state': order.state}request.env is scoped to the session user. Record rules and access rights apply. Use request.env['my.model'].sudo() to bypass them when the business logic requires it - document why.
Returning Responses#
Render a QWeb template#
return request.render('my_module.my_template', values_dict)Return a file download#
pdf_bytes = generate_pdf()
return request.make_response(
pdf_bytes,
headers=[
('Content-Type', 'application/pdf'),
('Content-Disposition', 'attachment; filename="report.pdf"'),
]
)Redirect#
return request.redirect('/destination-path')CSRF Protection#
Odoo enforces CSRF tokens on HTTP POST routes by default. For routes called from external systems (webhooks, mobile apps, external APIs):
@http.route('/webhook/stripe', type='http', auth='none', methods=['POST'], csrf=False)
def stripe_webhook(self, **kwargs):
payload = request.httprequest.data
# validate Stripe signature before processing
...
return 'OK'Set csrf=False explicitly; never disable CSRF on routes that modify user data without an alternative authentication check.
Five Gotchas#
1. 403 on POST without CSRF token#
If a browser form POSTs to your route and the CSRF token is missing, Odoo returns 403. Include in your QWeb form, or set csrf=False only for non-browser callers.
2. auth='none' and missing database cursor#
With auth='none', there is no automatic database connection. If you need the ORM, open a cursor explicitly with odoo.registry(request.db).cursor() inside a with block.
3. Inherited routes require website=True on both#
If you override a website route in a child module, the child's @http.route decorator must also specify website=True. Omitting it removes the website context from the override.
4. Path converter type mismatch raises 404#
in the route pattern only matches numeric segments. A request with a non-numeric value silently returns 404 instead of a useful error.
5. Controllers are not reloaded without restarting Odoo#
In development, changes to controller files require a server restart (or --dev=reload). The --dev flag watches for file changes and reloads automatically; without it, edits to controllers are invisible until restart.
ERPeek can identify all custom controller routes defined in a codebase, the authentication mode of each, and which routes have CSRF disabled. Useful for security audits. See the contact page for a demo.

