← Blog
odoocustom-uisdkjson-rpcintegration

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

How to Build a Custom Odoo Interface in Any Language — ODXProxy blog cover

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 forExtending existing back-office screensPurpose-built screens for one task or one device
Team skillsPython + Odoo framework + OWLAny language your team already ships
Native mobile / offlineAwkwardNatural
Deploy cadenceTied to the Odoo upgrade cycleIndependent of Odoo
Business logicFree — you are in the ORMMust be called explicitly, not reimplemented
Access rulesEnforced automaticallyStill 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.

Your interface calls one SDK method, which sends a JSON-RPC request to ODXProxy, which runs execute_kw on Odoo

Two shapes carry every argument, and they are identical in every language:

  • params is a JSON array — the positional arguments, so a domain filter is params[0].
  • keyword is 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.

Design your native types (structs, interfaces, DTOs) from the 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.

The .NET client v1.0.0 is Windows 11 x64 only — its network core ships as a native 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:

CodeHTTPMeaningWhat the interface should do
(Odoo's own)200Odoo logic/validation/access errorShow the message inline on the field or form — this one is the user's to fix
-32000401Bad or missing proxy keyFail loudly to your logs; never show the user — it's a deploy bug
-32001400Action not on the allowlistA code bug; fail in CI, not in production
-32002400call_method with no fn_nameSame — a code bug
-32003504Upstream Odoo timeoutOffer retry; keep the user's unsaved input
-32004502Can't reach OdooShow an "ERP unreachable" state, retry with backoff
-32005500Internal proxy errorGeneric error state, alert on it
0403Proxy license invalidOps 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.