How to Build a Custom Odoo Interface in Any Language
Odoo's backend UI fits everyone and nobody. Build a custom Odoo interface — web, mobile, kiosk, or bot — on nine JSON-RPC actions, with SDKs in six languages.
ODXProxy Team · Sep 9, 2026 · 12 min read

Somewhere in your warehouse there is a person holding a phone, wearing a glove, trying to hit a breadcrumb link in Odoo's backend list view. That is the moment you start wanting a custom Odoo interface — one screen, three fields, a button the size of a fist. The good news is that you do not have to fork Odoo's web client or write an OWL module to get it. Odoo's data is reachable over JSON-RPC, and a custom interface is just a client of that API: a React app, a SwiftUI screen, a Slack bot, a kiosk in a browser tab. This guide shows how to build one — the mental model, the introspection step that keeps it honest, and the same operation written in five languages.
Two ways to build a custom Odoo interface
There are genuinely two answers, and picking the wrong one costs months. Be honest about which problem you have.
Build inside Odoo (OWL, views, QWeb). You write an Odoo module: XML views, maybe an OWL component, maybe a portal template. The interface lives in Odoo's own web client and inherits everything — record rules, translations, the ORM, workflow buttons, printing.
Build outside Odoo (an API client). You write an app in whatever stack fits the device, and it talks to Odoo over the External API. Odoo stays the system of record; your app owns the pixels.
| Inside Odoo (OWL/views) | Outside Odoo (API client) | |
|---|---|---|
| Best for | Extending existing back-office screens | Purpose-built screens for one task or one device |
| Team skills | Python + Odoo framework + OWL | Any language your team already ships |
| Native mobile / offline | Awkward | Natural |
| Deploy cadence | Tied to the Odoo upgrade cycle | Independent of Odoo |
| Business logic | Free — you are in the ORM | Must be called explicitly, not reimplemented |
| Access rules | Enforced automatically | Still enforced — the API runs as an Odoo user |
If the answer is "our accountants need one more filter on the invoice list," build inside Odoo. If the answer is "our drivers need a two-button screen that works in a truck," build outside. The rest of this article is about the second case.
The whole backend is nine actions
The reason a custom interface is tractable in any language is that the surface you are coding against
is tiny. Through ODXProxy, every call is one POST /api/odoo/execute with one of nine
allowed actions: search_count, search, read, fields_get, search_read, create, write,
unlink, and call_method. Everything an Odoo screen does — load a list, open a record, save it,
press a workflow button — is a combination of those.

Two shapes carry every argument, and they are identical in every language:
paramsis a JSON array — the positional arguments, so a domain filter isparams[0].keywordis a JSON object — the kwargs:fields,limit,offset,order,context.
Learn those two, and porting a screen from TypeScript to Swift is mechanical. The details of each action's argument shape are in the actions reference, and the query language itself is covered in Odoo domain filters.
Step 1: introspect the model before you design the screen
The most expensive mistake in a custom Odoo UI is designing the screen from a mockup and assuming
the field names. Odoo instances are customized; the field your client calls "delivery date" might be
scheduled_date, date_deadline, or x_studio_promised_date. Ask the instance before you draw
anything — that is what fields_get is for:
{
"id": "ui-introspect-1",
"action": "fields_get",
"model_id": "stock.picking",
"params": [],
"keyword": {
"attributes": ["string", "type", "required", "readonly", "relation", "selection"]
},
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": "<the Odoo user API key>"
}
}The response tells you the real field names, their types, which are required (those become required
inputs in your form), which are readonly (render them as text, not inputs), the exact selection
values for a status chip, and the relation target of every many2one so you know what a second lookup
would cost. Sample two or three real records with search_read afterwards to confirm value shapes —
a many2one comes back as [id, "Display Name"], and an empty field comes back as false, not null.
fields_get output, not from memory. It takes ten minutes and removes the entire class of bugs where the UI renders "undefined" against a production database.Step 2: build the screen in the language that fits the device
Here is the same operation — load today's ready deliveries for the warehouse screen — written
against each documented SDK. Same nine actions, same params and keyword, idiomatic in each
language. Every SDK holds the proxy URL and the proxy key once, binds the target Odoo instance, and
gives you one method per action.
TypeScript / React — the web dashboard or kiosk:
import { init, search_read, OdooLogicError } from "@terrakernel/odxproxy-client-js";
init({
instance: { url: ODOO_URL, db: "prod", user_id: 2, api_key: ODOO_API_KEY },
odx_api_key: ODX_API_KEY,
gateway_url: "https://your-proxy.example.com",
});
type Picking = { id: number; name: string; partner_id: [number, string] | false; state: string };
const res = await search_read<Picking>(
"stock.picking",
[[["picking_type_code", "=", "outgoing"], ["state", "=", "assigned"]]],
{ fields: ["name", "partner_id", "scheduled_date", "state"], order: "scheduled_date asc", limit: 50 }
);
const pickings = res.result ?? [];Swift / SwiftUI — the phone in the glove:
import ODXProxyClientSwift
struct Picking: Codable, Identifiable, Sendable {
let id: Int
let name: String
let state: String
}
let response: OdxServerResponse<[Picking]> = try await OdxApi.searchRead(
model: "stock.picking",
params: OdxParams([[["picking_type_code", "=", "outgoing"], ["state", "=", "assigned"]]]),
keyword: OdxClientKeywordRequest(fields: ["name", "partner_id", "scheduled_date", "state"])
)Python — the FastAPI or Django service behind your frontend:
from terrakernel.odxproxyclient import ODXProxyClient
with ODXProxyClient("https://your-proxy.example.com", ODX_API_KEY) as client:
erp = client.for_instance(url=ODOO_URL, db="prod", user_id=2, api_key=ODOO_API_KEY)
pickings = erp.search_read(
"stock.picking",
params=[[["picking_type_code", "=", "outgoing"], ["state", "=", "assigned"]]],
keyword={"fields": ["name", "partner_id", "scheduled_date", "state"], "limit": 50},
)PHP — the Laravel or WordPress portal:
use OdxProxy\Odx;
$kw = (new KeywordRequest())
->setFields(['name', 'partner_id', 'scheduled_date', 'state'])
->setLimit(50);
$pickings = Odx::searchRead(
'stock.picking',
[['picking_type_code', '=', 'outgoing'], ['state', '=', 'assigned']],
$kw
);C# / .NET — the Windows floor terminal:
using var client = OdxClient.Create(baseUrl: "https://your-proxy.example.com", apiKey: ODX_API_KEY);
var odoo = new OdooInstance { Url = ODOO_URL, UserId = 2, Db = "prod", ApiKey = ODOO_API_KEY };
Picking[]? pickings = await client.ExecuteAsync(
action: OdxAction.SearchRead,
modelId: "stock.picking",
instance: odoo,
resultType: AppJson.Default.PickingArray,
paramsJson: """[[["picking_type_code","=","outgoing"],["state","=","assigned"]]]"""u8.ToArray(),
keywordJson: """{"fields":["name","partner_id","state"],"limit":50}"""u8.ToArray());A Java client covers the JVM with the same action set. Read your language's page
before you write against it, because the naming has genuinely drifted between SDKs: unlink is
exposed as remove in the JavaScript and Java clients, call_method is call() in PHP, and the
.NET client deliberately has no per-action methods at all — one ExecuteAsync plus an OdxAction
enum, with params and keyword passed as raw JSON bytes. The argument shape varies too: most SDKs
take the full positional params array as documented, while PHP's searchRead takes the domain
directly. The wire protocol underneath is identical in every case — if your language isn't listed,
implementing the envelope by hand against the API reference is a short afternoon.
x86_64-pc-windows-msvc binary. For a macOS, Linux, or mobile .NET target, use another SDK or call the JSON-RPC endpoint directly.Step 3: the buttons that aren't CRUD
A real interface has verbs, not just nouns. "Validate this delivery" is not a write — it is an Odoo
business method that moves stock, checks availability, and may raise a wizard. Those go through
call_method, the ninth action, with a non-empty fn_name:
{
"id": "ui-validate-1",
"action": "call_method",
"model_id": "stock.picking",
"fn_name": "button_validate",
"params": [[41]],
"keyword": {},
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": "<the Odoo user API key>"
}
}This is the line that separates a custom interface from a reimplementation of Odoo. Do not recompute
stock moves, tax, or sequence numbers in your app — call the method Odoo already has and render what
it returns. If fn_name is missing or empty you get HTTP 400 with code -32002, which is the proxy
refusing to guess. Calling custom Odoo methods with call_method covers the
argument shapes, including how to pass a context.
Step 4: make errors legible in the UI
This is where custom interfaces usually go wrong, and it is worth getting right before your first
demo. An HTTP 200 response can still be a failure. Odoo logic errors — validation, access rules, a
missing required field — come back with status 200 and a populated error object. Only
proxy-layer failures use non-200 codes. So every call needs a two-step check: look at the status,
then look at error, and only then read result.
The payoff is that the JSON-RPC error code maps cleanly onto what the user should see:
| Code | HTTP | Meaning | What the interface should do |
|---|---|---|---|
| (Odoo's own) | 200 | Odoo logic/validation/access error | Show the message inline on the field or form — this one is the user's to fix |
-32000 | 401 | Bad or missing proxy key | Fail loudly to your logs; never show the user — it's a deploy bug |
-32001 | 400 | Action not on the allowlist | A code bug; fail in CI, not in production |
-32002 | 400 | call_method with no fn_name | Same — a code bug |
-32003 | 504 | Upstream Odoo timeout | Offer retry; keep the user's unsaved input |
-32004 | 502 | Can't reach Odoo | Show an "ERP unreachable" state, retry with backoff |
-32005 | 500 | Internal proxy error | Generic error state, alert on it |
0 | 403 | Proxy license invalid | Ops alert; the app is down until fixed |
Most SDKs give you this as typed exceptions to branch on — AuthError, OdooTimeoutError,
OdooLogicError and friends in the JavaScript and Python clients, an OdxProxyError enum with a case
per code in Swift, OdxException subclasses in .NET, and a single OdxServerErrorException on the
JVM where you branch on getCode(). The distinction that matters for a UI: an Odoo logic error is
content to render, while a -32000 is a bug to page someone about. Full detail in
Odoo API error handling.
Where the keys live
A custom interface has one security question, and it has one right answer. There are two separate
secrets: the proxy key that travels in the x-api-key header, and the Odoo user's api_key that
travels inside odoo_instance in the body. Neither belongs in a browser bundle, a mobile binary, or
anything a user can open a debugger on.
So the shape is: your frontend calls your backend, and your backend calls the proxy. Your server holds both keys, decides which Odoo user the request runs as, and exposes only the operations your UI actually needs. That also disposes of CORS, which is the other thing that bites people trying to call an ERP straight from a browser — see Odoo API CORS for why the browser blocks it and why the server-side hop is the fix rather than a workaround.
One more thing to keep in mind while designing screens: the API runs as an Odoo user, so record
rules still apply. Your beautifully paginated list may return fewer rows than you expect, silently,
because ir.rule clauses are ANDed into every search. If your counts look wrong, read why Odoo
returns fewer records over the API before blaming your domain.
What this approach doesn't give you
Worth saying plainly, because "custom interface" can be oversold. Going outside Odoo's web client
means you do not inherit its views, its translations, its report engine, its chatter widget, or its
wizard dialogs — if a call_method returns an action that would normally open a wizard, your app has
to decide what to do with it. You are also responsible for the state your screen holds between calls;
there is no form-view lifecycle doing it for you. And the API surface is deliberately nine actions
wide, which is a feature for safety and a constraint for anything exotic.
ODXProxy itself is early — v0.1.0 for most clients, with the .NET package at 1.0.0 and Windows-only. The value proposition is architectural, not a benchmark: one authenticated entry point, an action allowlist, per-request instance routing, and a consistent error envelope, so that a Swift app, a Next.js dashboard, and a Slack bot all talk to your ERP the same way.
Where to go next
The path from here is short: introspect the model with fields_get, prototype the read with
search_read, wire the write path with
create, write, and unlink, and put the workflow buttons behind
call_method. If you are still deciding how the app authenticates at all,
start with how to authenticate to the Odoo API — user id plus API key,
never a password — and if the interface you have in mind is a storefront or content site rather than
an operations screen, Odoo as a headless CMS covers that shape in detail.
Pick the language your team actually ships in; the nine actions are waiting in all of them. The Python SDK and the JavaScript SDK are the two most complete places to start.