Odoo /web/dataset/call_kw vs the External API
Copying /web/dataset/call_kw or /web/dataset/search_read out of DevTools breaks outside the browser. What that endpoint really is, and what to call instead.
ODXProxy Team · Jul 20, 2026 · 9 min read

There is a well-worn path into Odoo integration work: open the Odoo web client, open DevTools, watch the
network tab while you click a list view, and find a tidy JSON request to /web/dataset/call_kw carrying
exactly the data you need. Copy as cURL, paste into your script, run it — and get an HTML login page, or a
session-expired error, or a 403. This guide explains what /web/dataset/call_kw actually is, why the
copied request stops working the moment it leaves the browser, and which endpoint you should be calling
instead for a server-to-server integration.
What that endpoint is
/web/dataset/call_kw is the web client's own transport. It is an internal controller in Odoo's web
module whose job is to let the JavaScript UI running in your browser invoke ORM methods on the server. Its
body is JSON-RPC 2.0 and looks like this:
{
"jsonrpc": "2.0",
"method": "call",
"params": {
"model": "res.partner",
"method": "search_read",
"args": [[["is_company", "=", true]], ["name", "email"]],
"kwargs": { "limit": 80, "context": { "lang": "en_US", "tz": "Europe/Brussels" } }
},
"id": 42
}Recognisable, and deceptively close to what you want. The model, method, args, kwargs shape is the
same execute_kw call the documented external API makes. Older Odoo versions also exposed a dedicated
/web/dataset/search_read route, which is why that string still turns up in blog posts and Stack Overflow
answers; newer versions funnel everything through call_kw. Both are internal either way.
The important part is not in the body at all. It is in the headers.
Why the copied request fails
The web client authenticates with a session cookie, not an API key. Your browser holds a session_id
cookie issued by /web/session/authenticate when you logged in, and every call_kw request rides on it.
Strip the cookie — which is exactly what happens when you paste the URL and body into a script — and Odoo
has no idea who you are.
That gives you four distinct failure modes, and they look nothing alike:
- No cookie at all. The route requires an authenticated user, so you get an authentication error or an
HTML redirect to the login page. A client expecting JSON chokes on the
<of<!DOCTYPE html>. - A copied cookie that has expired. Sessions are not permanent. This works on your laptop for an hour and fails in production at 3am, which is the worst possible version of this bug.
- A copied cookie that gets invalidated. Logging out in the browser, a password change, or a server restart with a cleared session store can all end the session your script was borrowing.
- The route changed. These are internal controllers with no compatibility guarantee. Odoo moves,
renames, and removes
/web/dataset/*routes between major versions, because the only client it promises to keep working is its own UI.
/web/dataset/call_kw is not a public API — it is an implementation detail of the web client. An integration built on it is an integration that breaks on an Odoo upgrade, with no deprecation notice, because it was never a supported contract.There is a second, subtler problem: the context in a browser request is populated by the web client with
the logged-in user's language, timezone and allowed companies. Copy the request and you inherit whoever's
session you copied. Change nothing and your integration silently formats dates in someone else's timezone.
What to call instead
Odoo's supported external API is a different surface entirely, and it is designed for exactly your use
case: no session, no cookie, credentials on every call. It is available as XML-RPC (/xmlrpc/2/common to
authenticate, /xmlrpc/2/object to call) and as JSON-RPC at /jsonrpc, where a call is dispatched by
service name:
{
"jsonrpc": "2.0",
"method": "call",
"params": {
"service": "object",
"method": "execute_kw",
"args": [
"prod", 2, "<the Odoo user API key>",
"res.partner", "search_read",
[[["is_company", "=", true]]],
{ "fields": ["name", "email"], "limit": 80 }
]
},
"id": 1
}Same ORM call, stateless authentication. Since Odoo 14 you can pass a user API key in the password position, which is what you want for an integration — it is revocable per key and does not tie your service to somebody's login password. Session login is the interactive path and expects the real password, with two-factor prompts if the user has 2FA enabled; do not build a server integration on it.
The cost of /jsonrpc is that positional args array: seven elements, order-sensitive, with the domain
nested one level deeper than feels natural, and no allowlist over which model methods a caller can reach.
The same call through a proxy
This is the shape ODXProxy exists to flatten. The proxy takes a named request over JSON-RPC 2.0, validates the action against an allowlist, and forwards it to whichever Odoo instance the request names:
{
"id": "callkw-1",
"action": "search_read",
"model_id": "res.partner",
"params": [[["is_company", "=", true]]],
"keyword": { "fields": ["name", "email"], "limit": 80 },
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": "<the Odoo user API key>"
}
}Sent as:
curl -X POST https://proxy.example.com/api/odoo/execute \
-H "x-api-key: <the proxy API key>" \
-H "content-type: application/json" \
-d @request.jsonThree things change relative to both of the endpoints above.
Two separate secrets, deliberately. x-api-key in the HTTP header authenticates you to the proxy.
odoo_instance.api_key is the Odoo user's key and is what Odoo checks. They are never the same value,
and conflating them is the single most common first-day mistake — see
how to authenticate to the Odoo API for the full split.
Named fields instead of positional args. params is a JSON array of positional arguments to the ORM
method (default []) and keyword is a JSON object of keyword arguments (default {}). The domain still
goes in params as the first positional argument — hence params: [[...]] — but the field list, limit and
context are named in keyword rather than counted into a seven-element array.
An allowlist. Nine actions are directly callable: search_count, search, read, fields_get,
search_read, create, write, unlink, and call_method. Anything else goes through call_method with
a non-empty fn_name, which is what you use for a model method the shorter actions do not cover:
{
"id": "callkw-2",
"action": "call_method",
"model_id": "account.move",
"fn_name": "action_post",
"params": [[1042]],
"keyword": {},
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": "<the Odoo user API key>"
}
}That is the same generality the browser's call_kw gives you — any method, any model — but reached
explicitly, through a named field, rather than by borrowing the UI's transport. More on that action in
calling arbitrary Odoo methods with call_method.
Reading the response, whichever endpoint you use
All three surfaces return a JSON-RPC 2.0 envelope, and all three share the same trap: an Odoo logic error
comes back with HTTP 200. Access errors, validation errors, a missing field, a malformed domain — these
are Odoo doing its job, so the transport succeeded and the status is 200 with a populated error object
and no result.
{
"jsonrpc": "2.0",
"id": "callkw-1",
"result": [{ "id": 14, "name": "Deco Addict", "email": "deco.addict@example.com" }]
}Only failures below Odoo use non-200 statuses. Through the proxy those are 401 for a bad or missing
x-api-key (code -32000), 400 for an action outside the allowlist (-32001) or a call_method with no
fn_name (-32002), 504 for an upstream timeout (-32003), 502 when the Odoo instance is unreachable
(-32004), and 500 for an internal proxy error (-32005).
So the check is always two steps, in this order:
- Is the HTTP status
200? If not, this is a transport- or proxy-level failure — surface the JSON-RPCerror. - If it is
200, does the body have anerrorfield? If yes, surface it. Only then readresult.
result as undefined on every Odoo access or validation error and report success. If you take one habit from the browser-to-server migration, take this one — the full pattern is in Odoo API error handling.When DevTools is still the right tool
None of this means the network tab is useless — it is the fastest model discovery tool Odoo has. Watching
call_kw fly past tells you which model backs a screen, which fields the view reads, and what the real
method name is behind a button you want to automate. Use it for reconnaissance, then reimplement the
call against the supported API.
The programmatic equivalent, once you know the model, is fields_get, which describes a model's fields
without any guessing:
{
"id": "callkw-3",
"action": "fields_get",
"model_id": "sale.order",
"params": [],
"keyword": { "attributes": ["string", "type", "relation", "required"] },
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": "<the Odoo user API key>"
}
}Summary
/web/dataset/call_kw | /jsonrpc | proxy /api/odoo/execute | |
|---|---|---|---|
| Intended caller | the Odoo web UI | external integrations | external integrations |
| Auth | session cookie | db + uid + key in args | x-api-key header + odoo_instance |
| Stability | internal, changes between versions | supported | supported |
| Call shape | model/method/args/kwargs | 7-element positional args | named action + params/keyword |
| Method allowlist | none | none | 9 actions, others via call_method |
If you found this article because you copied a call_kw request and it stopped working: the request was
never wrong, it was just never yours. Move the same ORM call to the external API, put the credentials in
the request instead of a cookie, and it will keep working across upgrades.
Where to go next
- Get the credential model right in how to authenticate to the Odoo API.
- Compare the transports directly in Odoo search_read over XML-RPC and JSON-RPC.
- Map the surface in the Odoo API endpoints reference.
- See the exact request and response contract in the API reference, or start from the Python SDK.