Some situations are universal for every Odoo project I work on. These are usually cases when the user wants to get or put data out/in Odoo outside of the Odoo UI. It can be an external mobile application that needs to know the inventory levels, or a third-party CRM system that has to transfer its data into Odoo, or a payment system that wants to send a webhook to Odoo upon a successful payment.
Odoo 19 framework changes mostly relate to the internal implementation of the models’ ORM methods. The public API mostly stayed the same. So if you are familiar with developing custom modules in Odoo, you are already most of the way there. For the Odoo 14 and Odoo 15 developers, the main API changes relate to the ways the models’ fields are declared (for example, access modifiers like invisible/readonly are now modelled as recordsets instead of being passed as strings in the ‘attrs’ dictionary) and some internal rewrites of the framework-related JSON routes machinery to make it more consistent.
The controller part (the http.Controller classes with @http.route decorators) has a public API that was not changed in the recent versions. This article will show how to create a new REST-like API endpoint based on an existing Odoo model and consume an external API from Odoo.
Why Odoo Does Not Have 'Real' REST by Default
Odoo's native web protocol is JSON-RPC 2.0 with an added layer of HTTP on top of it. When you call /web/dataset/call_kw from the browser, that's JSON-RPC, not REST, and it's not something a third-party system can be expected to speak natively. For external systems communicating with your Odoo instance, they most likely expect proper HTTP methods and RESTful-style URLs.
That means in practice, "REST API integration in Odoo" almost certainly means one of the following:
- You can either wrote http.Controller routes which implement REST endpoints (translating HTTP methods to model crud operations)
- You're calling an external REST API from somewhere in your Python code (a cron job, a button click, a webhook handler)
- You're using the XML-RPC protocol to access Odoo's native API, which is an officially supported method requiring no custom code on your part, only some credential management and a library on the receiving end
How to make a REST API in Odoo 19
Let's say you have a custom model, library.book, and you want to expose a simple list and detailed views of it over HTTP for some external app to consume. For brevity's sake, I'll skip model definitions and the database setup, and only show the code necessary to expose it as a REST API.
# controllers/main.py
import json
from odoo import http
from odoo.http import request, Response
class LibraryAPIController(http.Controller):
def _json_response(self, data, status=200):
return Response(
json.dumps(data),
status=status,
mimetype='application/json',
)
@http.route('/api/v1/books', type='http', auth='user',
methods=['GET'], csrf=False)
def list_books(self, **kw):
domain = []
if kw.get('available'):
domain.append(('state', '=', 'available'))
books = request.env['library.book'].search_read(
domain,
['id', 'name', 'author_id', 'state'],
limit=int(kw.get('limit', 50)),
offset=int(kw.get('offset', 0)),
)
return self._json_response({'status': 'success', 'data': books})
@http.route('/api/v1/books/<int:book_id>', type='http', auth='user',
methods=['GET'], csrf=False)
def get_book(self, book_id):
book = request.env['library.book'].browse(book_id)
if not book.exists():
return self._json_response(
{'status': 'error', 'message': 'Book not found'}, status=404
)
return self._json_response({
'status': 'success',
'data': {
'id': book.id,
'name': book.name,
'author': book.author_id.name,
'state': book.state,
},
})
@http.route('/api/v1/books', type='http', auth='user',
methods=['POST'], csrf=False)
def create_book(self, **post):
if not post.get('name'):
return self._json_response(
{'status': 'error', 'message': 'name is required'}, status=400
)
try:
book = request.env['library.book'].create({
'name': post['name'],
'author_id': int(post['author_id']) if post.get('author_id') else False,
})
return self._json_response(
{'status': 'success', 'id': book.id}, status=201
)
except Exception as e:
return self._json_response(
{'status': 'error', 'message': str(e)}, status=500
)
A few things to look out for if you're actually implementing this:
- auth='user' requires a valid Odoo session or authentication before making the call. In the case of machine-to-machine calls, this is generally not the way to go – you want API key auth instead (see below) since you don't want to handle any session cookies in your external system.
- csrf=False is a must for all non-Odoo-originated POST, PUT, and DELETE calls. Not specifying this parameter will result in all POSTs failing silently with 403, and this particular detail caused me a major headache while working with one of my clients who insisted on the fact that the endpoint 'doesn't work at all'. It did – it just wasn't accepting any requests that were missing the Odoo CSRF token.
- search_read is performing double duty here by reading and deserializing everything at once.
API Key Authentication for External Callers
When it comes to machine-to-machine communication, never rely on session authentication. The simple key validation via header will suffice to get most of what you need done:
class SecureAPIController(http.Controller):
def _check_api_key(self):
api_key = request.httprequest.headers.get('X-API-Key')
if not api_key:
return False
valid_key = request.env['ir.config_parameter'].sudo().get_param(
'library.api_key'
)
return api_key == valid_key
@http.route('/api/v1/secure/books', type='http', auth='none', csrf=False)
def secure_list_books(self, **kw):
if not self._check_api_key():
return Response(
json.dumps({'error': 'Invalid API key'}),
status=401, mimetype='application/json',
)
books = request.env['library.book'].sudo().search_read([], ['name', 'state'])
return Response(
json.dumps({'data': books}), mimetype='application/json',
)
Take a look at auth='none' together with sudo() in the method: without a logged-in user, when using auth='none', each ORM call must use sudo() to avoid access restriction issues. While this approach is perfectly fine in a scoped and intentionally public API endpoint, it also requires you to provide all authorization logic manually. Odoo does nothing to prevent a malicious user with a leaked key from accessing all data in that model.
Consuming an External API from Odoo
The reverse direction - Odoo contacting an external API - tends to be a much easier task, as it's nothing but requests in a model method, cron, or a button handler.
import requests
import logging
_logger = logging.getLogger(__name__)
class LibraryBook(models.Model):
_inherit = 'library.book'
def action_fetch_metadata_from_isbn(self):
for book in self:
if not book.isbn:
continue
try:
response = requests.get(
f'https://openlibrary.org/isbn/{book.isbn}.json',
timeout=10,
)
response.raise_for_status()
data = response.json()
book.write({
'name': data.get('title', book.name),
'page_count': data.get('number_of_pages'),
})
except requests.exceptions.RequestException as e:
_logger.warning('ISBN lookup failed for %s: %s', book.isbn, e)
Two rules of thumb that will save you a debugging session: always set a timeout (external services may hang up and block the Odoo process) and catch RequestException rather than a general exception because, even if a call fails, you want your module and cron to keep working anyway.
Webhooks: The Reverse-REST Pattern
To get an external application to trigger events on Odoo (for example, a payment confirmation or an order update), you will create a webhook receiver that is technically identical to the REST controller above except for signature validation:
import hmac, hashlib
class WebhookController(http.Controller):
@http.route('/webhook/payment', type='json', auth='none',
methods=['POST'], csrf=False)
def payment_webhook(self):
data = request.jsonrequest
signature = request.httprequest.headers.get('X-Signature')
secret = request.env['ir.config_parameter'].sudo().get_param(
'payment.webhook_secret'
)
expected = hmac.new(secret.encode(), json.dumps(data).encode(),
hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature or '', expected):
return {'status': 'error', 'message': 'Invalid signature'}
request.env['payment.transaction'].sudo()._handle_webhook(data)
return {'status': 'success'}
hmac.compare_digest instead of == matters here because it's a constant-time comparison, which avoids leaking timing information that could theoretically help an attacker guess the signature byte by byte. Small things, easy to skip, worth doing anyway.
"Write an http.Controller, choose your authentication mechanism, and use sudo() carefully" is how REST integration looks in Odoo 19. There is no change in architecture because of version 19; the only difference is the update in the syntax of ORM. It’s the mundane things that make the REST integration work: handling timeouts when calling external systems, doing CSRF correctly on incoming calls, and using an API key that is not stored as plain text somewhere in some configuration variable.
To read more about How to Configure Odoo REST API Module in Odoo 18, refer to our blog How to Configure Odoo REST API Module in Odoo 18.