← Blog
odooapiexternal-apijson-rpcintegration

The Odoo External API: A JSON-RPC Integration Guide

Odoo's External API lets you read and write ERP data programmatically. This guide covers JSON-RPC vs XML-RPC, authentication, a first call, and runnable code.

ODXProxy Team · Jul 7, 2026 · 12 min read

The Odoo External API: A JSON-RPC Integration Guide — ODXProxy blog cover

Almost every Odoo integration starts with the same question: how do I get data in and out of Odoo from my own code? The answer is the Odoo External API — the programmatic interface that lets an external application read, create, and update records the same way the web client does. What trips people up is the shape of it: the Odoo External API is not a REST service with tidy GET /partners routes. It is an RPC interface, historically reached over XML-RPC and, increasingly, over JSON-RPC 2.0. This guide explains what the External API actually is, why JSON-RPC is the cleaner way to talk to it, how authentication works, and gives you real, runnable calls for reading and writing data.

What the Odoo External API actually is

Odoo exposes one universal server method, execute_kw, that stands in front of the entire ORM. Instead of a separate endpoint per model, you call execute_kw with four things:

  • the database name,
  • your user id (uid, an integer),
  • an API key for that user, and
  • the model, method, and arguments you want to run.

That single method is why the External API can do so much with so few endpoints: reading partners, posting an invoice, and creating a sale order are all the same call with a different model and method name. There is no REST resource tree to learn and no per-model URL to version — you change the model and the method, not the route.

Because it wraps the ORM directly, the External API can call essentially any model method your user has access to, which makes it far more capable than a hand-written REST layer that only exposes the handful of endpoints someone remembered to build.

JSON-RPC vs XML-RPC: which transport to use

Search for "Odoo external API" and most older tutorials reach for XML-RPC — Python's xmlrpc.client, endpoints like /xmlrpc/2/common and /xmlrpc/2/object, and XML payloads on the wire. That still works, and if you are maintaining a legacy integration you will see it in Odoo 16, 17, and 18 alike. But for anything new, JSON-RPC 2.0 is the better transport:

  • JSON is native to every modern stack. No XML serialization quirks, no wrestling with <struct>/<member> payloads — just objects your language already understands.
  • It is easy to inspect. You can read a JSON-RPC request in Postman or a log line and understand it at a glance.
  • It is the same envelope everywhere. Every call and every response share one shape, so once you can make one request you can make all of them.

Whichever transport you pick, the mental model is identical — one method (execute_kw), a model, a method name, positional args, and keyword args. This guide uses JSON-RPC throughout; for a side-by-side of the same search_read call written both ways, see search_read over XML-RPC vs JSON-RPC.

JSON-RPC and XML-RPC are two encodings of the same External API. Switching transports doesn't change which models or methods you can reach — it only changes how the request is serialized on the wire.

Reaching the External API through a proxy

Talking to Odoo's External API directly means every client holds Odoo database credentials, handles per-instance URLs, and re-implements the same request envelope. ODXProxy is a reverse proxy that sits in front of one or more Odoo instances and gives you a single, unified JSON-RPC 2.0 endpoint for all of them. Your application talks to the proxy; the proxy forwards the call to the right Odoo instance. Every example below uses that endpoint, POST /api/odoo/execute.

That introduces one thing you must get right from the start: there are two different secrets, and conflating them is the most common source of a mysterious 401.

  • x-api-key — the proxy's key. It authenticates your app to the proxy and travels as an HTTP header. It is the same for every Odoo instance behind that proxy.
  • odoo_instance.api_key — the Odoo user's key. It authenticates the actual ERP call and is sent per request inside the body.

They are never the same value. If you're calling Odoo's External API directly without a proxy, only the second one exists; the moment a gateway is in front, keep the two straight.

What you need to authenticate

For the ERP call itself you need two pieces of identity from Odoo:

Your user id (uid) is the database id of your record in res.users. Turn on developer mode, open Settings → Users & Companies → Users, click your user, and read the id from the URL. That integer is your uid.

Your API key replaces your password for API calls (Odoo 14 and later). Open your avatar → My Profile → Account Security → New API Key, name it, and copy the value — you only see it once.

An Odoo API key inherits all the permissions of the user who owns it. Create a dedicated integration user with only the access rights it needs, rather than minting a key on an administrator account.

Your first External API call

The main entry point is POST /api/odoo/execute. Every request carries the proxy key in the x-api-key header and describes the Odoo call in the body. Here is a search_read that reads five companies — a good first call because it only needs read access:

curl -X POST https://your-proxy.example.com/api/odoo/execute \
  -H "Content-Type: application/json" \
  -H "x-api-key: $ODX_PROXY_KEY" \
  -d '{
    "id": "first-call-1",
    "action": "search_read",
    "model_id": "res.partner",
    "params": [[["is_company", "=", true]]],
    "keyword": { "fields": ["name", "email"], "limit": 5 },
    "odoo_instance": {
      "url": "https://erp.example.com",
      "db": "prod",
      "user_id": 2,
      "api_key": "<the Odoo user API key>"
    }
  }'

On success you get HTTP 200 and a JSON-RPC envelope whose result holds what Odoo returned:

{
  "jsonrpc": "2.0",
  "id": "first-call-1",
  "result": [
    { "id": 9, "name": "Gemini Furniture", "email": "info@gemini.example" },
    { "id": 14, "name": "Azure Interior", "email": "hello@azure.example" }
  ]
}

That's a complete round trip: authenticated at the proxy, authenticated at Odoo, and back with data.

The shape of every request

The reason the External API scales to your whole ERP with so little surface area is that every request is the same five-part shape:

  • action — one of nine allowed actions: search_count, search, read, fields_get, search_read, create, write, unlink, and call_method. Anything else is rejected before it reaches Odoo.
  • model_id — the Odoo model, e.g. res.partner or sale.order.
  • params — a JSON array of positional arguments (default []). For search_read, the first positional argument is the domain filter, which is why it's wrapped one level deep: [[["is_company", "=", true]]].
  • keyword — a JSON object of keyword arguments (default {}) — here fields and limit.
  • id — a string you choose; it's echoed back so you can correlate responses with requests.

Anything the External API can do that isn't one of the first eight actions goes through call_method plus a non-empty fn_name. Posting an invoice, for instance, calls the action_post method on account.move:

{
  "id": "post-invoice-1",
  "action": "call_method",
  "model_id": "account.move",
  "fn_name": "action_post",
  "params": [[42]],
  "keyword": {},
  "odoo_instance": {
    "url": "https://erp.example.com",
    "db": "prod",
    "user_id": 2,
    "api_key": "<the Odoo user API key>"
  }
}

call_method is the escape hatch that keeps the External API's full power available: any model method your user can call, you can reach.

Reading and writing data

The same envelope covers the full lifecycle. To create a record, send the values in params:

{
  "id": "create-partner-1",
  "action": "create",
  "model_id": "res.partner",
  "params": [{ "name": "Acme Robotics", "email": "hello@acme.example", "is_company": true }],
  "odoo_instance": {
    "url": "https://erp.example.com",
    "db": "prod",
    "user_id": 2,
    "api_key": "<the Odoo user API key>"
  }
}

The result is the new record's id. To update it, write takes the ids and the changed fields; to remove it, unlink takes the ids:

{
  "id": "update-partner-1",
  "action": "write",
  "model_id": "res.partner",
  "params": [[57], { "phone": "+1-555-0100" }],
  "odoo_instance": {
    "url": "https://erp.example.com",
    "db": "prod",
    "user_id": 2,
    "api_key": "<the Odoo user API key>"
  }
}

Read, create, update, delete, count, describe fields, call any method — all one shape, one endpoint, one envelope.

The HTTP 200 trap every integration hits

This is where External API integrations quietly break: you cannot tell success from failure by the HTTP status alone. There are two distinct layers of failure, and you must check them in order.

Layer 1 — proxy-level failures use a non-200 status. A wrong or missing proxy key returns HTTP 401; an action outside the allowlist returns 400; an upstream timeout returns 504. These carry a JSON-RPC error with a negative code:

HTTPcodeMeaning
401-32000Missing or wrong x-api-key
400-32001action not in the allowlist
400-32002call_method with no fn_name
502-32004Could not reach the Odoo instance
504-32003Upstream Odoo call timed out

Layer 2 — Odoo's own errors come back with HTTP 200. If the proxy accepted your request but Odoo rejected it — a bad Odoo API key, a missing access right, a validation failure — Odoo returns a logic error that is passed straight through with a 200 status and a populated error object:

{
  "jsonrpc": "2.0",
  "id": "create-partner-1",
  "error": {
    "code": 200,
    "message": "You are not allowed to create 'Contact' records.",
    "data": { "name": "odoo.exceptions.AccessError" }
  }
}

The error.code of 200 here is Odoo's own code for a server-side exception — it has nothing to do with the HTTP status. That collision is exactly why you must check both layers.

A 200 response is not proof of success. Always check the HTTP status first, and then — even on a 200 — check whether the body has a populated error field before you read result.

Doing it properly in code

Putting the two-step check together, a robust External API call in Python looks like this:

import requests

PROXY_URL = "https://your-proxy.example.com/api/odoo/execute"
PROXY_API_KEY = "<the proxy x-api-key>"

payload = {
    "id": "first-call-1",
    "action": "search_read",
    "model_id": "res.partner",
    "params": [[["is_company", "=", True]]],
    "keyword": {"fields": ["name", "email"], "limit": 5},
    "odoo_instance": {
        "url": "https://erp.example.com",
        "db": "prod",
        "user_id": 2,
        "api_key": "<the Odoo user API key>",
    },
}

resp = requests.post(PROXY_URL, headers={"x-api-key": PROXY_API_KEY}, json=payload, timeout=20)

# Step 1: proxy-level failures are non-200.
resp.raise_for_status()

body = resp.json()

# Step 2: a 200 can still carry an Odoo error.
if body.get("error"):
    err = body["error"]
    raise RuntimeError(f"Odoo error {err['code']}: {err['message']}")

partners = body["result"]
print(partners)

Because JSON-RPC is just JSON over HTTP, the same request is trivial to reproduce in any language — fetch in JavaScript, HttpClient in C#, net/http in Go — or in Postman: a POST to /api/odoo/execute, an x-api-key header, and the JSON body above. If you'd rather not hand-roll the envelope and the two-step check on every call, the SDKs wrap exactly this: configure the proxy URL and x-api-key once, bind an Odoo instance once, and call session.search_read(...), with error codes mapped to typed exceptions.

The same call from JavaScript and C#

Nothing about the External API is Python-specific — the envelope is identical, and so is the two-step check. In JavaScript (Node or the browser), it's a single fetch:

const resp = await fetch("https://your-proxy.example.com/api/odoo/execute", {
  method: "POST",
  headers: { "Content-Type": "application/json", "x-api-key": process.env.ODX_PROXY_KEY },
  body: JSON.stringify({
    id: "first-call-1",
    action: "search_read",
    model_id: "res.partner",
    params: [[["is_company", "=", true]]],
    keyword: { fields: ["name", "email"], limit: 5 },
    odoo_instance: {
      url: "https://erp.example.com",
      db: "prod",
      user_id: 2,
      api_key: process.env.ODOO_API_KEY,
    },
  }),
});

if (!resp.ok) throw new Error(`Proxy error ${resp.status}`); // step 1: non-200 is a proxy failure
const body = await resp.json();
if (body.error) {                                            // step 2: a 200 can still carry an Odoo error
  throw new Error(`Odoo error ${body.error.code}: ${body.error.message}`);
}
console.log(body.result);

In C#, the shape is the same — note @params, because params is a reserved keyword:

using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", Environment.GetEnvironmentVariable("ODX_PROXY_KEY"));

var payload = new
{
    id = "first-call-1",
    action = "search_read",
    model_id = "res.partner",
    @params = new object[] { new object[] { new object[] { "is_company", "=", true } } },
    keyword = new { fields = new[] { "name", "email" }, limit = 5 },
    odoo_instance = new
    {
        url = "https://erp.example.com",
        db = "prod",
        user_id = 2,
        api_key = Environment.GetEnvironmentVariable("ODOO_API_KEY"),
    },
};

var resp = await http.PostAsJsonAsync("https://your-proxy.example.com/api/odoo/execute", payload);
resp.EnsureSuccessStatusCode();                       // step 1: proxy-level failures are non-200
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
if (doc.RootElement.TryGetProperty("error", out var err)) // step 2: a 200 can still carry an Odoo error
{
    throw new Exception($"Odoo error {err.GetProperty("code")}: {err.GetProperty("message")}");
}

Same three ingredients in every language: the x-api-key header, the JSON body, and the two-step check. Official SDKs exist for Python, JavaScript/TypeScript, C#/.NET, Java/Kotlin, PHP, and Swift if you'd rather not hand-roll the envelope.

A note on versions and expectations

ODXProxy is early (v0.1.0), so treat anything beyond the nine shipped actions as roadmap rather than a promise. The External API surface itself, though, is stable across recent Odoo releases — the execute_kw model and the API-key auth flow described here apply to Odoo 16, 17, and 18, whether you reach them over XML-RPC or the JSON-RPC shape used above.

Where to go next

You now have the whole picture of the Odoo External API: one universal method behind it, JSON-RPC as the cleanest transport, the two-secret model when a proxy is involved, real read/write calls, and the two-step error check that survives the HTTP 200 trap.