← Blog
odoojson-rpcapiauthenticationintegration

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

Odoo /web/dataset/call_kw vs the External API — ODXProxy blog cover

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.
The last point is the one that should decide it for you. /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.json

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

  1. Is the HTTP status 200? If not, this is a transport- or proxy-level failure — surface the JSON-RPC error.
  2. If it is 200, does the body have an error field? If yes, surface it. Only then read result.
A client that branches on HTTP status alone will read 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/jsonrpcproxy /api/odoo/execute
Intended callerthe Odoo web UIexternal integrationsexternal integrations
Authsession cookiedb + uid + key in argsx-api-key header + odoo_instance
Stabilityinternal, changes between versionssupportedsupported
Call shapemodel/method/args/kwargs7-element positional argsnamed action + params/keyword
Method allowlistnonenone9 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