API Versions (v1 and v2)
ODXProxy speaks to Odoo two ways — execute_kw over /jsonrpc (v1) and Odoo's JSON-2 API (v2). Which to use, what changes, and how to migrate.
ODXProxy has two API versions. They share the same x-api-key auth, the same
headers, and the same JSON-RPC 2.0 response envelope; they differ in how the proxy
talks to Odoo, and therefore in the request shape.
| v1 | v2 | |
|---|---|---|
| Endpoints | POST /api/odoo/execute, POST /api/odoo/version (alias /v1/odoo/*) | POST /v2/odoo/execute, POST /v2/odoo/version |
| Upstream | Odoo's execute_kw over /jsonrpc | Odoo's JSON-2 API, POST /json/2/<model>/<method> |
| Odoo versions | any, up to 21 | 19 and later |
| ODXProxy | any version | 0.9.0 or later |
| Request | action (one of 9) + positional params + keyword | model_id + any public method + one named kwargs object |
| Odoo credentials | url, db, user_id, api_key | url, db, api_key — no user_id |
Which one to use
| Your Odoo | Use |
|---|---|
| 18 or older | v1 — Odoo has no JSON-2 yet. |
| 19, 20, 21 | Either. Prefer v2 for new code that must survive an upgrade to 22; stay on v1 if the same code also has to reach older instances. |
| 22 and later | v2 — Odoo 22 removes /jsonrpc, which v1 depends on. |
v1 is not deprecated: it is the only way to reach Odoo 18 and older, and it stays for as long as customers run those versions.
If you don't know an instance's version, ask the proxy once and cache the answer per URL:
curl -s -X POST http://localhost:6600/v2/odoo/version \
-H "x-api-key: $PROXY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "id": "1", "url": "https://erp.example.com" }'{ "jsonrpc": "2.0", "id": "1", "result": { "version_info": [20, 0, 0, "final", 0, "e"], "version": "20.0+e" } }version_info[0] of 19 or more means v2 is available. Error -32006, or a major
version below 19, means use v1. Don't probe before every call — the answer only
changes when the instance is upgraded. Every SDK has a cached helper
for this.
The same call on both
A search_read on v1:
{
"id": "1",
"action": "search_read",
"model_id": "res.partner",
"params": [[["is_company", "=", true]]],
"keyword": { "fields": ["name", "email"], "limit": 10 },
"odoo_instance": { "url": "https://erp.example.com", "db": "prod", "user_id": 2, "api_key": "<ODOO_USER_API_KEY>" }
}and on v2:
{
"id": "1",
"model_id": "res.partner",
"method": "search_read",
"kwargs": {
"domain": [["is_company", "=", true]],
"fields": ["name", "email"],
"limit": 10
},
"odoo_instance": { "url": "https://erp.example.com", "db": "prod", "api_key": "<ODOO_USER_API_KEY>" }
}Three differences show up immediately:
- Named arguments only. v2 has no positional
paramsand nokeyword. Every argument goes inkwargsunder the Odoo method's Python parameter name, so the domain is"domain"— the list itself, without v1's extra wrapping. - Any public method.
methodnames the Odoo method directly; there's no action allowlist and nofn_name. Odoo still refuses private (_-prefixed) methods, and every call still runs under the Odoo user's own permissions. - No
user_id. JSON-2 derives the user from the API key. Auser_idsent by a v1-shaped client is ignored, so one instance object can serve both versions.
The response envelope is identical. See Actions and methods for the
kwargs of every common method.
v2 in production: what to know
An API key, not a password — and it can expire
v2 accepts only an Odoo API key. On Odoo 20+ its scope must be rpc (the
default in Odoo's key wizard). Keys of non-admin users expire on a schedule;
an expired key comes back as HTTP 200 with error code 401 — Odoo's, not the
proxy's -32000. Surface it to the end user so they can issue a new key.
Multi-database hosts: dbfilter applies
JSON-2 selects the database with a header, and Odoo's dbfilter filters it. On
hosts that pick the database from the hostname (for example dbfilter = ^%d$,
common on multi-tenant hosting), odoo_instance.url must be that database's own
hostname, or every call fails with -32006. v1 isn't affected. A -32006 from
a server you know runs 19+ is a database/hostname problem, not a version problem.
- Company context is explicit. Odoo applies no company selection unless the
call's
contextcarriesallowed_company_ids;company_dependentfields then read as the API user's default company. Send it inkwargs.context. createalways returns a list of ids —{"vals_list": [{...}]}→[42], even for one record. (On v1, creating from a single dict returns a bare id.)- Arguments are checked against the method signature. An unknown or misspelled
key, or
idssent to an@api.modelmethod such assearchorcreate, fails with error code422. Keys are never camelCased:vals_list, notvalsList. - Errors carry Odoo's HTTP status. An Odoo-side error arrives on HTTP 200 with
error.codeset to401,403,404,409,422, or500, anderror.dataset to Odoo's error object. Only409(a lock conflict) is worth retrying. See Error codes.
Two Odoo 20 data changes apply to both versions, so they matter whichever you pick:
- Binary fields read as
{"content": "<base64>", "filename": "...", "size": n}rather than a bare base64 string. - Empty values are still
false, notnull, and datetimes are still UTC strings ("YYYY-MM-DD HH:MM:SS").
Migrating from v1 to v2
Most calls translate mechanically. ids and the values dict become named keys, and
the domain loses one level of nesting:
v1 action + params | v2 method + kwargs |
|---|---|
search_read, params: [domain], keyword: {fields, limit, offset, order} | search_read, {domain, fields, limit, offset, order} |
search, params: [domain] | search, {domain, limit, offset, order} |
search_count, params: [domain] | search_count, {domain} |
read, params: [ids, fields] | read, {ids, fields} |
fields_get, keyword: {attributes} | fields_get, {attributes} (and allfields) |
create, params: [vals] | create, {vals_list: [vals]} → returns [id] |
write, params: [ids, vals] | write, {ids, vals} |
unlink, params: [ids] | unlink, {ids} |
call_method, fn_name: "action_post", params: [ids] | action_post, {ids} |
call_method, fn_name: "name_search", params: ["Acm"], keyword: {limit: 5} | name_search, {name: "Acm", limit: 5} |
keyword.context | kwargs.context |
For a method outside this table, look up its Python parameter names — on Odoo 19+,
/doc/<model> in the web UI lists them — and send each one by name. Pass ids only
when the method works on records.
Try it
- The Postman collection has a v2 folder
that runs top to bottom: version,
search_read,search_count,fields_get,create,read,write, both kinds of method call, andunlink(which deletes the record it created). Each request has saved example responses, including the typical errors. - Every SDK supports both versions, with v2 as a separate session or namespace, so moving a call to v2 is mostly a constructor change.
- The full v2 endpoint schema is in the API reference.