← Blog
odooodoo-20apiapi-keyjson-rpcmigration

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

Odoo 20 API Changes That Break Integrations Silently — ODXProxy blog cover

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.

This is part 2 of a five-part series on Odoo 19 vs Odoo 20. Part 1 covers the whole release from a developer's point of view.

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)s

The 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:

  1. After upgrading a staging copy, generate a new key for each integration user, with scope RPC.
  2. Swap it into the integration's configuration and run its full test suite against staging.
  3. 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.

Two different secrets are in play if your apps go through ODXProxy: the proxy's own 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.0Odoo 20.0
ir.model.access, ir.ruleone model, ir.access
ir.config_parameter.get_param / set_paramget_str, get_int, get_bool, get_float and set_*
ir.attachment.datasraw
stock.move.product_uom, stock.move.line.product_uom_id, purchase.order.line.product_uom_id, mrp.bom(.line).product_uom_iduom_id everywhere
stock.scrapgone; a scrap is a stock.move with is_scrap and scrap_reason_tag_ids
hr.leave.type, hr.leave.holiday_status_idhr.work.entry.type, hr.leave.work_entry_type_id
hr.contract.type, hr.version.contract_type_idhr.employee.type, employee_type_id
account.groupgone; accounts form a hierarchy through account.account.parent_id
res.bankgone; bank name and address live on res.partner.bank
mrp.bom.consumptiongone, 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

  1. Restore production into a 20.0 staging instance.
  2. Generate RPC-scoped keys for every integration user.
  3. Run each integration's fields_get check against staging and fix the mappings it flags.
  4. Grep client code for b64decode and other binary handling, and add the dict case.
  5. Grep for /xmlrpc/2/db, db.dump and db.list, and move them to /web/database/* or pg_dump.
  6. 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

The request contract used above is documented in the ODXProxy API reference, and the Python SDK wraps the same calls with typed errors.