- Byte Legions
- Odoo Technical
Controllers are the seam where Odoo stops being a closed ERP and starts being a platform other systems can talk to. Every payment callback, every partner portal page, every webhook from a shipping provider, and every mobile app request arrives through one. That makes a odoo 19 custom web controller the most externally exposed code you will write, and it is usually the least reviewed.
The pattern in most codebases I audit is the same: someone needed an endpoint quickly, copied a snippet with auth="none" and csrf=False, shipped it, and moved on. It works. It also sits on the public internet with a direct line into the ORM. This guide covers how the request stack actually behaves, how to choose route types deliberately, what each authentication mode really guarantees, and how to build an endpoint you would be comfortable defending in a security review.
Where Controllers Fit in the Odoo 19 Request Stack
A controller method is not a standalone script. By the time your code runs, Odoo has already done a substantial amount of work on your behalf, and understanding that sequence explains most of the behaviour that surprises developers later.
An incoming request is matched against the routing map assembled from every @http.route decorator in every installed module. Odoo then resolves the database, opens a cursor, builds an environment, and populates the request object before dispatching to your method. That environment is what request.env gives you, and it is already bound to a specific user determined by the route’s authentication mode.
Two consequences follow directly. First, your method runs inside a transaction: raising an exception rolls back every ORM write you made, which is the behaviour you want and the reason you should not catch exceptions broadly just to return a friendly message. Second, the recordset permissions in play are the ones attached to whichever user the auth mode selected, not the ones you assumed while testing as an administrator.
Controllers live in a controllers/ directory, imported from the module’s __init__.py, and inherit from odoo.http.Controller. Routes are registered at module load, so a new endpoint requires an upgrade of the module rather than just a server restart.
Choosing Between HTTP and JSON Route Types
The type parameter on the route decorator decides how Odoo parses the incoming request and how it interprets whatever you return. Picking the wrong one produces confusing symptoms: parameters that arrive empty, responses double-encoded, or a client that cannot read your error.
When type="http" Is the Right Choice
Use type="http" whenever a browser is involved or the caller is an external system that speaks plain REST. Query string and form parameters are passed to your method as keyword arguments, and you are responsible for the response object.
from odoo import http
from odoo.http import request
class InventoryPortal(http.Controller):
@http.route(
"/my/stock/<int:product_id>",
type="http",
auth="user",
website=True,
)
def product_stock(self, product_id, **kwargs):
product = request.env["product.product"].browse(product_id).exists()
if not product:
return request.not_found()
return request.render(
"my_module.portal_stock_template",
{"product": product},
)Note website=True. It attaches the website layout, language handling, and the current website record to the request. Leave it off for machine-facing endpoints, because it adds work you do not need and pulls in behaviour that can surprise you on a pure API route.
When type="json" Is the Right Choice
Use type="json" when the caller is the Odoo web client or another consumer speaking JSON-RPC. Odoo parses the request body, hands the parameters to your method as keyword arguments, and serialises whatever you return, so you return a plain dictionary and never touch a response object.
The detail that catches teams out is the envelope. A JSON route wraps your return value in a JSON-RPC structure, and errors come back as a JSON-RPC error object with a 200 status code rather than a 4xx. If you are building an API for a third party that expects conventional REST semantics, that envelope will fight you. Use type="http" and request.make_json_response() instead, which gives you full control over both body and status code.
@http.route(
"/api/v1/orders",
type="http",
auth="none",
methods=["POST"],
csrf=False,
save_session=False,
)
def create_order(self, **kwargs):
payload = request.get_json_data()
if not payload.get("partner_ref"):
return request.make_json_response(
{"error": "partner_ref is required"}, status=400
)
# ... validated work happens here
return request.make_json_response({"status": "ok"}, status=201)Endpoints shaped like this are exactly what external automation platforms consume, and they pair naturally with the kind of workflow tooling we covered in wiring Odoo into an external automation stack.
Authentication Modes and What They Actually Guarantee
The auth parameter is the single most consequential character in your decorator, and its three values are routinely misread.
auth="user" requires an authenticated session and runs as that user. Record rules and access rights apply normally. This is the correct default for anything a logged-in human reaches.
auth="public" does not mean unauthenticated. It means the route is reachable without a session, and when no session exists the request runs as the public user, a real user record with deliberately minimal rights. If a session does exist, the request runs as that user instead. This is the right mode for website pages and portal entry points.
auth="none" means no environment user is set up at all. It exists for endpoints that must run before or outside the normal authentication flow, such as provider callbacks. You take on full responsibility for verifying the caller and for choosing which user context to operate under.
The mistake worth calling out explicitly: reaching for sudo() because a query returned nothing. An empty recordset on a public route is usually access rules working correctly. Bypassing them with sudo() on a publicly reachable endpoint hands anonymous callers administrator reach into that model. When you genuinely need elevated access, scope it to the narrowest possible operation and validate every identifier the caller supplied before you use it.
Building a Secure Public Endpoint Step by Step
For an endpoint exposed to a third party, work through five things in order.
Verify the caller yourself. With auth="none" nothing else will. Read a shared secret or signature from request.httprequest.headers, compare it with hmac.compare_digest rather than == to avoid timing leaks, and store the expected value in a system parameter or environment variable rather than in source.
Constrain the method. Always pass methods=["POST"] for anything that writes. Without it your write endpoint answers GET requests too, which means it can be triggered by a link.
Disable CSRF only where it belongs. csrf=False is required for external POST callers because they cannot supply an Odoo CSRF token. It must never appear on a route a logged-in user’s browser reaches, because that is precisely the protection it removes.
Validate before you touch the ORM. Treat every value in the payload as hostile: check types, check ranges, and confirm that referenced records both exist and belong to the caller. browse() on an arbitrary integer from an untrusted body is how one customer reads another customer’s order.
Control what you return. Build the response dictionary field by field. Returning a recordset dump or an unfiltered read() leaks internal fields, and once an integrator depends on them you cannot remove them.
Mistakes That Reach Production, and the Checklist That Catches Them
Four failures account for most of the controller problems I see in live systems. Broad try/except blocks that swallow exceptions and therefore silently commit half-finished transactions. Long-running work performed inside the request, holding a worker and a database cursor while an external API times out, when a queued job would do. Missing pagination on list endpoints, which behaves perfectly against a demo database and falls over at a hundred thousand records. And route paths without a version prefix, which turns the first breaking change into a coordination exercise with every integrator at once.
Before an endpoint ships, confirm each of these:
- Is
auththe narrowest mode that still works, and does the route avoidsudo()unless it is scoped and justified? - Is
methodsrestricted, and doescsrf=Falseappear only on genuinely external routes? - Is every caller-supplied identifier validated for existence and ownership before use?
- Does the route return explicit fields and a meaningful HTTP status code on failure?
- Are list endpoints paginated with an enforced maximum page size?
- Does the path carry a version segment such as
/api/v1/? - Is there a test hitting the route as an anonymous caller and asserting it is refused?
If you are exposing Odoo to a payment provider, a marketplace, or a mobile client and want that surface reviewed before it goes live, book a consultation and we will go through your routes with you.
Conclusion
Controllers reward precision. The decorator arguments are not boilerplate: type determines how your data is parsed and returned, auth determines whose permissions apply, and methods plus csrf determine who can trigger the route at all. Choose each one deliberately, validate everything that crosses the boundary, keep the request short, and version the path from day one. Endpoints built that way survive integrations, audits, and the upgrade after next without becoming the part of the codebase nobody wants to touch.
Frequently Asked Questions
What is the difference between auth=”public” and auth=”none” in Odoo?
auth="public" runs the request as the public user when no session exists, so access rights and record rules still apply. auth="none" sets up no environment user at all and applies no access control, which means you must authenticate and authorise the caller yourself. Use public for website pages and none only for callbacks that cannot carry an Odoo session.
Do I need csrf=False on my Odoo controller?
Only on routes that external systems POST to, because those callers cannot supply an Odoo CSRF token. Never set it on a route reached by a logged-in user’s browser, since it removes protection against cross-site request forgery on an authenticated session.
Should I use type=”json” to build a REST API in Odoo?
Usually not. type="json" follows JSON-RPC conventions and wraps errors in an envelope returned with a 200 status, which most REST clients will not expect. Use type="http" with request.make_json_response() when you need conventional status codes and a response body you control.
Why does my new controller route return a 404?
Routes are registered when the module loads, so a new or renamed route needs a module upgrade rather than only a server restart. Also confirm the controllers package is imported from the module’s __init__.py and that the path does not collide with an existing route.
How should long-running work be handled inside an Odoo controller?
Move it out of the request. A controller holds a worker process and a database cursor for its entire duration, so a slow external call can exhaust your worker pool under load. Validate the input, persist a record, return a response immediately, and let a queued job or scheduled action do the heavy work.






Comments are closed