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

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.
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.
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, andcall_method. Anything else is rejected before it reaches Odoo.model_id— the Odoo model, e.g.res.partnerorsale.order.params— a JSON array of positional arguments (default[]). Forsearch_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{}) — herefieldsandlimit.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:
| HTTP | code | Meaning |
|---|---|---|
| 401 | -32000 | Missing or wrong x-api-key |
| 400 | -32001 | action not in the allowlist |
| 400 | -32002 | call_method with no fn_name |
| 502 | -32004 | Could not reach the Odoo instance |
| 504 | -32003 | Upstream 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.
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.
- Start with the credentials in How to authenticate to the Odoo API —
the
uid, the API key, and the two-key model in full. - Turn the domain filter into precise queries with Odoo search_read, in depth, or see the same call over both transports in search_read over XML-RPC vs JSON-RPC.
- Reach any model method through the escape hatch in Calling Odoo model methods with call_method.
- Make every call robust with the full two-layer model in Odoo API error handling.
- Read the complete request and response contract in the API reference, or skip the boilerplate with the Python SDK.