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

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.
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:ERRORThree endpoints are on the clock:
/xmlrpcand/xmlrpc/2/common,/xmlrpc/2/object: the XML-RPC External API./jsonrpc: the samecommon/objectservices 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/jsonThe 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 ascreate).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:
- No login step. There is no
common.authenticateand nouidto 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, gets401 Invalid apikey. - Named arguments only. The controller binds the body with
signature.bind(records, **kwargs). Positionalargslists are gone, so you need each method's parameter names. - Errors use HTTP status codes. An Odoo
UserErrorreturns 422,AccessError403,MissingError404, and an unexpected exception 500. The JSON body describes the exception. This is unlike the classic/jsonrpcendpoint, where an Odoo error arrives with HTTP 200 and anerrormember. - Private methods are refused. As with
execute_kw, a method whose name starts with_raisesAccessError. Calling an@api.modelmethod withidsreturns 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 call | JSON-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
- 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.
- Put an adapter behind one interface. Most integrations already wrap
execute_kwin a helper. Give that helper a second implementation that speaks JSON-2, and switch per method. - Translate the arguments. Use the table above. The bugs show up in methods with several positional parameters, so check each one against its signature.
- Rewrite error handling. Code that caught
xmlrpc.client.Fault, or checked theerrormember of a/jsonrpcresponse, has to check the HTTP status now. - Issue RPC-scoped keys for every integration user, and remove the login step.
- 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
- Part 1: Odoo 19 vs Odoo 20: what changed for developers
- Part 2: Odoo 20 API changes that break integrations silently
- Part 4: Odoo 20's built-in MCP server: what it exposes
- Part 5: Migrating custom modules to Odoo 20: the silent breaks
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.