← Blog
odooodoo-20xml-rpcjson-rpcjson-2migration

Odoo XML-RPC Is Deprecated: Migrating to the JSON-2 API

Odoo's XML-RPC and JSON-RPC endpoints are deprecated and scheduled for removal in Odoo 22. How the JSON-2 API works, and how to move execute_kw calls over.

ODXProxy Team · Oct 1, 2026 · 7 min read

Odoo XML-RPC Is Deprecated: Migrating to the JSON-2 API — ODXProxy blog cover

Upgrade to Odoo 20 and the server log starts repeating the same warning on every external call: Odoo's XML-RPC API is deprecated, and so is the classic JSON-RPC endpoint. Both still work in Odoo 20.0. Both are scheduled for removal in Odoo 22. That gives every integration built on execute_kw roughly two major versions to move to the replacement, the JSON-2 API. This guide explains what the deprecation covers, how JSON-2 differs from execute_kw, and how to migrate calls one at a time without a big-bang rewrite. Everything here is checked against the 20.0 source.

This is part 3 of a five-part series on Odoo 19 vs Odoo 20. Part 2 covers the other API changes in 20.0, including the new API key scopes, which JSON-2 depends on.

What exactly is deprecated

The notice lives in odoo/addons/rpc/controllers/__init__.py, and the XML-RPC and JSON-RPC controllers log it as a warning on every request:

The /xmlrpc, /xmlrpc/2 and /jsonrpc endpoints are deprecated in Odoo 19
and scheduled for removal in Odoo 22. Please report the problem to the
client making the request.
Mute this logger: --log-handler odoo.addons.rpc.controllers.xmlrpc:ERROR

Three endpoints are on the clock:

  • /xmlrpc and /xmlrpc/2/common, /xmlrpc/2/object: the XML-RPC External API.
  • /jsonrpc: the same common / object services over JSON-RPC.

The warning names the logger so an administrator can mute it, but muting it only hides the countdown. Treat each warning as a list entry: the log line tells you a client is still on the old API, and the request tells you which one.

Two things are not covered by this notice. /web/dataset/call_kw, the web client's internal route, was never a public API, as covered in what /web/dataset/call_kw is and what to use instead. And the ORM methods themselves (search_read, create, write, action_post and so on) are not deprecated. Only the transport that carries the call is changing.

How the JSON-2 API works

JSON-2 exists in both 19.0 and 20.0, so you can start migrating before you upgrade. The route is defined in odoo/addons/rpc/controllers/json2.py:

POST /json/2/<model>/<method>
Authorization: bearer <API key with RPC scope>
X-Odoo-Database: <database name>        (needed when the server hosts several)
Content-Type: application/json

The model and method move into the URL. The body is a JSON object of named arguments:

  • ids: the records to call the method on (omit it for model-level methods such as create).
  • context: an optional context dict, for example {"lang": "en_US"}.
  • every other key: a keyword argument of the method, by its Python parameter name.

The response body is the method's return value, as plain JSON. There is no envelope. A method that returns records returns their ids.

Four differences from execute_kw matter in practice:

  1. No login step. There is no common.authenticate and no uid to carry. The bearer key identifies the user, and it must carry the RPC scope (bearer_scope='rpc'). An unscoped 19.0-era key, or an MCP-scoped key, gets 401 Invalid apikey.
  2. Named arguments only. The controller binds the body with signature.bind(records, **kwargs). Positional args lists are gone, so you need each method's parameter names.
  3. Errors use HTTP status codes. An Odoo UserError returns 422, AccessError 403, MissingError 404, and an unexpected exception 500. The JSON body describes the exception. This is unlike the classic /jsonrpc endpoint, where an Odoo error arrives with HTTP 200 and an error member.
  4. Private methods are refused. As with execute_kw, a method whose name starts with _ raises AccessError. Calling an @api.model method with ids returns 422.

Translating execute_kw calls to JSON-2

The mechanical part of the migration is turning positional arguments into named ones. These are the parameter names in the 20.0 ORM (odoo/orm/models.py):

execute_kw callJSON-2 request
search_read, args [domain], kwargs {fields, limit}POST /json/2/res.partner/search_read {"domain": [...], "fields": [...], "limit": 5}
search_count, args [domain]POST /json/2/res.partner/search_count {"domain": [...]}
read, args [ids], kwargs {fields}POST /json/2/res.partner/read {"ids": [7], "fields": [...]}
create, args [vals]POST /json/2/res.partner/create {"vals_list": [{...}]}
write, args [ids, vals]POST /json/2/res.partner/write {"ids": [7], "vals": {...}}
unlink, args [ids]POST /json/2/res.partner/unlink {"ids": [7]}
fields_get, kwargs {attributes}POST /json/2/res.partner/fields_get {"attributes": [...]}
action_post, args [ids]POST /json/2/account.move/action_post {"ids": [42]}

Note create: in the 20.0 ORM its parameter is vals_list, so send a list of dicts, even for one record. The response is a list of new ids.

Here is a search_read before and after. The XML-RPC version:

import xmlrpc.client

URL, DB, LOGIN, KEY = "https://erp.example.com", "prod", "integration@example.com", "<key>"

uid = xmlrpc.client.ServerProxy(f"{URL}/xmlrpc/2/common").authenticate(DB, LOGIN, KEY, {})
models = xmlrpc.client.ServerProxy(f"{URL}/xmlrpc/2/object")
partners = models.execute_kw(
    DB, uid, KEY, "res.partner", "search_read",
    [[["is_company", "=", True]]],
    {"fields": ["name", "email"], "limit": 5},
)

The JSON-2 version:

import requests

URL, DB, KEY = "https://erp.example.com", "prod", "<RPC-scoped API key>"


def json2(model, method, **kwargs):
    resp = requests.post(
        f"{URL}/json/2/{model}/{method}",
        headers={"Authorization": f"bearer {KEY}", "X-Odoo-Database": DB},
        json=kwargs,
        timeout=30,
    )
    if resp.status_code != 200:
        err = resp.json()                      # {"name", "message", "arguments", ...}
        raise RuntimeError(f"{resp.status_code} {err['name']}: {err['message']}")
    return resp.json()


partners = json2(
    "res.partner", "search_read",
    domain=[["is_company", "=", True]],
    fields=["name", "email"],
    limit=5,
)
new_ids = json2("res.partner", "create", vals_list=[{"name": "Acme Robotics"}])
json2("res.partner", "write", ids=new_ids, vals={"phone": "+1-555-0100"})

The same call with curl, useful for checking a key before you touch code:

curl -s -X POST https://erp.example.com/json/2/res.partner/search_count \
  -H "Authorization: bearer $ODOO_API_KEY" \
  -H "X-Odoo-Database: prod" \
  -H "Content-Type: application/json" \
  -d '{"domain": [["is_company", "=", true]]}'

A 401 here almost always means the key has no RPC scope. Generate a new one from the security tab of your user profile (New API Key), and pick scope RPC.

What an error looks like

A failing call returns a non-200 status and a body built by serialize_exception() in odoo/http/dispatcher.py:

{
  "name": "odoo.exceptions.AccessError",
  "message": "You are not allowed to access 'Contact' (res.partner) records.",
  "arguments": ["You are not allowed to access 'Contact' (res.partner) records."],
  "context": {},
  "debug": "Traceback (most recent call last): ..."
}

Map the status codes to your own error types rather than parsing message. Treat 422 as a validation problem the caller can fix, 403 as a permissions problem, 404 as a missing model, method or record, and 5xx as retryable only if the call was a read.

Migrating without a big-bang rewrite

  1. Inventory the callers. On a 20.0 (or late 19.0) server, every deprecation warning is a caller still on the old API. Group them by client and by method.
  2. Put an adapter behind one interface. Most integrations already wrap execute_kw in a helper. Give that helper a second implementation that speaks JSON-2, and switch per method.
  3. Translate the arguments. Use the table above. The bugs show up in methods with several positional parameters, so check each one against its signature.
  4. Rewrite error handling. Code that caught xmlrpc.client.Fault, or checked the error member of a /jsonrpc response, has to check the HTTP status now.
  5. Issue RPC-scoped keys for every integration user, and remove the login step.
  6. Turn the warning into an alert once you think you are done, so a forgotten cron job shows up before Odoo 22 does.

Where a gateway changes the picture

The size of this migration depends on how many places talk to Odoo directly. If ten services each build their own execute_kw calls, you have ten migrations. If they all go through one gateway, the transport is that gateway's concern, and the apps keep calling the same contract.

That is part of how ODXProxy is designed. Apps send one JSON-RPC 2.0 request shape to POST /api/odoo/execute, naming one of nine actions (search_read, search, read, fields_get, search_count, create, write, unlink, call_method). That contract does not name an Odoo transport at all, so an Odoo-side transport change belongs to one component rather than to every app. It does not change the data your apps see: 20.0's renamed fields and new binary shape come through as Odoo returns them.

The rest of the series

For a side-by-side of the two classic transports this replaces, see Odoo search_read over XML-RPC vs JSON-RPC. For the proxy's request contract, see the ODXProxy API reference.