Odoo 20 API Changes That Break Integrations Silently
Odoo 20 API changes for integrators: API keys now need the RPC scope, binary fields read as a dict, models and fields were renamed, and the db service is gone.
ODXProxy Team · Oct 1, 2026 · 9 min read

Most Odoo upgrades break custom modules loudly: a traceback on install, a view that will not load. The Odoo 20 API changes are different. An external integration (a sync job, a mobile app, a reporting script) can stop working after the upgrade without a single error in your own code. The key that authenticated yesterday is rejected today, an image field that used to be a string is now a dict, and a field your mapping relies on no longer exists. This guide covers the four changes in Odoo 20.0 that break API clients, with the source location for each and the fix.
1. API keys now need the RPC scope
This is the change most likely to take an integration down on upgrade day.
Odoo stores API keys in res.users.apikeys with a scope column. In 19.0 a key with no scope was a
global key: the credential check matched scope IS NULL OR scope = %(scope)s. In 20.0 the check
in odoo/addons/base/models/res_users.py matches only the exact scope:
-- Odoo 19.0
AND (scope IS NULL OR scope = %(scope)s)
-- Odoo 20.0
AND scope = %(scope)sThe scope field is now required=True, and the "New API Key" wizard offers a selection that
defaults to rpc. Every bearer route declares the scope it accepts: the JSON-2 API uses
bearer_scope='rpc', and the new MCP server uses bearer_scope='mcp'.
The detail that matters for older integrations: the same scoped check runs when a key is passed as
a password on the classic endpoints. When common.authenticate or execute_kw receives an API
key over XML-RPC or JSON-RPC, Odoo calls _check_credentials(scope='rpc', ...). So it does not
matter which transport your client uses. On 20.0, the Odoo key it carries must be an RPC-scoped key.
What Odoo's official upgrade does with existing keys that have no scope is not visible in the source. Do not assume they survive. The safe procedure:
- After upgrading a staging copy, generate a new key for each integration user, with scope RPC.
- Swap it into the integration's configuration and run its full test suite against staging.
- Repeat on production at cut-over, and revoke the old keys.
A key also does exactly one job now. An MCP key will not authenticate an RPC call, and an RPC key
will not authenticate against /mcp.
x-api-key header, and the Odoo user's key in odoo_instance.api_key. A rejected Odoo key comes back from Odoo as an error object. It is not the proxy's -32000, which only means the x-api-key header is wrong. On 20.0, odoo_instance.api_key must be an RPC-scoped key.There is an upside. If your apps hold only a proxy key and the Odoo keys live in one service, a scope change or a key rotation happens in one place rather than in every client. Odoo 20's stricter scopes reward that kind of key hygiene.
2. Binary fields come back as a dict
In 19.0, reading a binary field such as image_1920 or datas returned a bare base64 string. In
20.0, fields.Binary.convert_to_read() (odoo/orm/fields_binary.py) returns an object:
{
"content": "iVBORw0KGgoAAAANSUhEUgAA...",
"filename": "invoice.pdf",
"size": 48213
}filename is dropped when it is empty or equal to the field name. An empty field still reads as
false. This applies to read, search_read and web_read, so every external client sees it. A
client that does base64.b64decode(record["image_1920"]) now raises TypeError, because it is
decoding a dict.
Writing is backward compatible: a base64 string is still accepted, and so is a dict with content
and filename. The fix is on the read side. If you serve both versions during a migration, decode
defensively:
import base64
def binary_bytes(value):
"""Decode an Odoo binary field value from read/search_read on 19.0 or 20.0."""
if not value: # an empty binary field reads as False
return None
if isinstance(value, dict): # Odoo 20.0: {"content", "filename"?, "size"}
value = value["content"]
return base64.b64decode(value)ODXProxy passes params and keyword to Odoo unchanged and returns what Odoo returns, so a client
behind the proxy sees the 20.0 dict too. Put the helper in your client code.
3. Renamed and removed models and fields
An external client only learns that a model or field is gone when a call fails at runtime. These are the renames most likely to hit an integration:
| Odoo 19.0 | Odoo 20.0 |
|---|---|
ir.model.access, ir.rule | one model, ir.access |
ir.config_parameter.get_param / set_param | get_str, get_int, get_bool, get_float and set_* |
ir.attachment.datas | raw |
stock.move.product_uom, stock.move.line.product_uom_id, purchase.order.line.product_uom_id, mrp.bom(.line).product_uom_id | uom_id everywhere |
stock.scrap | gone; a scrap is a stock.move with is_scrap and scrap_reason_tag_ids |
hr.leave.type, hr.leave.holiday_status_id | hr.work.entry.type, hr.leave.work_entry_type_id |
hr.contract.type, hr.version.contract_type_id | hr.employee.type, employee_type_id |
account.group | gone; accounts form a hierarchy through account.account.parent_id |
res.bank | gone; bank name and address live on res.partner.bank |
mrp.bom.consumption | gone, no replacement |
Some failures are loud and some are not. Reading a field that no longer exists raises an error you will see. Writing a dict that contains a stale key can be worse, depending on how the receiving code treats unknown keys. And a domain that filters on a renamed field fails at query time, often deep in a scheduled job.
Introspect before you map
Do not hard-code field lists from memory or from a 19.0 tutorial. Ask the instance. fields_get
returns the live field definitions for a model, including fields added by installed modules:
curl -s -X POST https://your-proxy.example.com/api/odoo/execute \
-H "Content-Type: application/json" \
-H "x-api-key: $ODX_PROXY_KEY" \
-d '{
"id": "fg-stock-move",
"action": "fields_get",
"model_id": "stock.move",
"params": [],
"keyword": { "attributes": ["string", "type", "relation"] },
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": "<RPC-scoped Odoo user API key>"
}
}'A small check at startup turns a silent mapping bug into an explicit one. This version uses plain
requests and does the two-step error check, because an Odoo error arrives with HTTP 200:
import os
import uuid
import requests
PROXY = "https://your-proxy.example.com/api/odoo/execute"
INSTANCE = {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": os.environ["ODOO_API_KEY"], # the Odoo user's key, RPC scope
}
def odoo(action, model, params=None, keyword=None):
resp = requests.post(
PROXY,
headers={"x-api-key": os.environ["ODX_PROXY_KEY"]}, # the proxy's key
json={
"id": str(uuid.uuid4()),
"action": action,
"model_id": model,
"params": params or [],
"keyword": keyword or {},
"odoo_instance": INSTANCE,
},
timeout=30,
)
body = resp.json()
if resp.status_code != 200: # proxy-layer failure
raise RuntimeError(f"proxy {resp.status_code}: {body['error']}")
if body.get("error"): # Odoo error on a 200
raise RuntimeError(f"odoo: {body['error']['message']}")
return body["result"]
REQUIRED = {"stock.move": {"uom_id", "product_id", "quantity"}}
for model, wanted in REQUIRED.items():
fields = odoo("fields_get", model, keyword={"attributes": ["type"]})
missing = wanted - fields.keys()
if missing:
raise SystemExit(f"{model} is missing {sorted(missing)}: check the Odoo version")The same check catches a removed model: fields_get on stock.scrap against 20.0 returns an Odoo
error, which the helper raises. For more on calling non-CRUD methods the same way, see
calling custom Odoo methods with call_method.
Know which version you are talking to
During a migration you may run 19.0 and 20.0 side by side. ODXProxy's
POST /api/odoo/version returns an instance's public version_info without Odoo credentials, so a
client can branch on the major version before it reads anything:
{
"jsonrpc": "2.0",
"id": "ver-1",
"result": {
"server_version": "20.0",
"server_version_info": [20, 0, 0, "final", 0, ""],
"server_serie": "20.0"
}
}Because every request names its own odoo_instance, one client can talk to both versions through
one endpoint and one proxy key. It still sees each version's own field names and shapes. The proxy
does not translate between them.
4. The db service is gone
In 19.0 the RPC dispatcher routed three services: common, db and object. In 20.0
dispatch_rpc in odoo/http/router.py accepts only common and object:
if service_name == 'object':
dispatch = odoo.service.model.dispatch
elif service_name == 'common':
dispatch = odoo.service.common.dispatch
else:
raise ValueError(f"Invalid service name: {service_name}")odoo/service/db.py was deleted. Any script that called db.list, db.dump, db.restore or
db.create_database over XML-RPC breaks. Database management now exists only as web routes under
/web/database/* (odoo/addons/web/controllers/database.py), protected by the master password.
Move backup automation to those routes, or better, to pg_dump plus a filestore copy on the host.
Data calls are unaffected: object and execute_kw still work.
5. XML-RPC and JSON-RPC are deprecated
/xmlrpc, /xmlrpc/2 and /jsonrpc still work in 20.0, but every request logs:
The /xmlrpc, /xmlrpc/2 and /jsonrpc endpoints are deprecated in Odoo 19 and scheduled for removal in Odoo 22.
Nothing breaks on 20.0, but your logs will fill with that warning. The replacement is the JSON-2
API, POST /json/2/<model>/<method> with a bearer key. The migration has its own article:
Odoo XML-RPC is deprecated: migrating to the JSON-2 API.
A test plan before cut-over
- Restore production into a 20.0 staging instance.
- Generate RPC-scoped keys for every integration user.
- Run each integration's
fields_getcheck against staging and fix the mappings it flags. - Grep client code for
b64decodeand other binary handling, and add the dict case. - Grep for
/xmlrpc/2/db,db.dumpanddb.list, and move them to/web/database/*orpg_dump. - Run every scheduled job once by hand against staging. Silent failures hide in jobs that only run at night.
Error handling deserves a second look while you are there: Odoo API error handling covers the full model, including why a 200 response can still carry an error.
The rest of the series
- Part 1: Odoo 19 vs Odoo 20: what changed for developers
- Part 3: Odoo XML-RPC is deprecated: migrating to the JSON-2 API
- Part 4: Odoo 20's built-in MCP server: what it exposes
- Part 5: Migrating custom modules to Odoo 20: the silent breaks
The request contract used above is documented in the ODXProxy API reference, and the Python SDK wraps the same calls with typed errors.