ODXProxy
SDKs

JavaScript SDK

A zero-dependency, typed JavaScript/TypeScript client for ODXProxy — init once, then call typed helpers.

@terrakernel/odxproxy-client-js is the official JavaScript/TypeScript client for ODXProxy. It's a thin, zero-runtime-dependency wrapper over the platform fetch: initialize a singleton once, then call typed helper functions (search_read, create, …). Every failure is thrown as a typed error.

Source: terrakernel/odxproxy-client-js · npm @terrakernel/odxproxy-client-js · Node 18+ or any modern browser · ships ESM + CJS + types · v2 API since 0.9.0.

Install

npm install @terrakernel/odxproxy-client-js
# or: yarn add / pnpm add

Initialize once

Call init a single time at startup, then import helpers anywhere. Calling init twice throws ("OdxProxyClient has already been initialized."); calling a helper before init throws ("OdxProxyClient has not been initialized.").

import { init, search_read } from "@terrakernel/odxproxy-client-js";

init({
  instance: {
    url: process.env.ODOO_URL!,      // your Odoo base URL
    db: process.env.ODOO_DB!,        // Odoo database name
    user_id: 2,                      // Odoo user id (integer)
    api_key: process.env.ODOO_API_KEY!,  // the Odoo USER's api key
  },
  odx_api_key: process.env.ODX_API_KEY!, // the PROXY's x-api-key
  gateway_url: "https://your-proxy.example.com", // optional; default https://gateway.odxproxy.io
  default_timeout_secs: 15,               // optional; sent as x-request-timeout
  default_context: { lang: "en_US", allowed_company_ids: [1] }, // optional; v2 calls only
});

Two different secrets

odx_api_key is the proxy's x-api-key; instance.api_key is the Odoo user's API key. They are never the same value — don't conflate them. Note the field names are snake_case (odx_api_key, gateway_url, user_id, api_key), matching the wire protocol.

Keep the keys server-side

Both secrets travel with every request (the proxy key as a header, the Odoo key inside the body). Initializing the client in untrusted browser code exposes both to end users. Prefer running it in a Node backend / trusted context; treat browser use as internal/trusted apps only.

  • gateway_url trims a trailing slash; defaults to https://gateway.odxproxy.io.
  • default_timeout_secs is sent as x-request-timeout; the client also aborts ~5s past that ceiling (45s when unset).

Fetch records

Helpers resolve to the JSON-RPC envelope { jsonrpc, id, result? } on success and throw on any failure. search_read is the common one — filter and read in one call. Pass a context (with at least tz) inside keyword:

type Partner = { id: number; name: string; email?: string };

const res = await search_read<Partner>(
  "res.partner",
  [[["is_company", "=", true]]],           // domain (params)
  { fields: ["id", "name", "email"], limit: 50, context: { tz: "UTC" } }
);

const partners = res.result ?? [];

The generic is the record type; search_read<Partner> resolves to result?: Partner[]. Every helper also takes an optional trailing opts for per-call control:

const ac = new AbortController();
await search_read("res.partner", [[]], { context: { tz: "UTC" } }, undefined, {
  timeoutSecs: 60,   // overrides default_timeout_secs for this call
  signal: ac.signal, // cancel via ac.abort()
});

For every action except search_read, the client strips fields / order / limit / offset from keyword before sending (they only apply to a combined search+read). context is always sent. The sort key is order (e.g. "name asc").

The 9 actions, mapped

Each allowed action is an exported function. Signature: fn(model, params, keyword, id?, opts?) — except call_method, which takes function_name after keyword.

ActionFunctionparams shaperesult
search_countsearch_count[[domain]]number
searchsearch[[domain]]number[] (IDs)
readread[[ids], [fields]]records
fields_getfields_get(model, keyword)field metadata
search_readsearch_read[[domain]]records
createcreate[{ fields }]number (new ID)
writewrite (alias update)[[ids], { fields }]boolean
unlinkremove[[ids]]boolean
call_methodcall_methodmethod args arraymethod return
import { create, write, remove, call_method } from "@terrakernel/odxproxy-client-js";

const kw = { context: { tz: "UTC" } };

const created  = await create("res.partner", [{ name: "Acme Inc", is_company: true }], kw);
const updated  = await write("res.partner",  [[42], { name: "Acme LLC" }], kw);
const deleted  = await remove("res.partner", [[42]], kw);

// call_method: function_name is the 4th positional arg (after keyword)
const confirmed = await call_method<boolean>("sale.order", [[42]], kw, "action_confirm");

call_method rejects with MissingFnNameError (proxy code -32002) if function_name is empty — no wasted round-trip. Anything outside the 9 actions must go through call_method. Mind the argument order: function_name comes after keyword, not before.

read takes its field list positionally ([[1, 2, 3], ["name", "email"]]) — fields in keyword is ignored for read.

v2 API (Odoo 19+)

From 0.9.0 the SDK exposes ODXProxy's v2 API (Odoo's JSON-2; needs ODXProxy 0.9.0+ and Odoo 19+) as the v2 namespace, on the same init() and the same bound instance. instance.user_id isn't sent — Odoo derives the user from the API key, which must be a real Odoo API key, not a password. default_context is merged into every v2 call; a call's own context keys win.

import { v2 } from "@terrakernel/odxproxy-client-js";

const partners = await v2.search_read<{ id: number; name: string }>("res.partner", {
  domain: [["is_company", "=", true]],
  fields: ["name"],
  limit: 10,
});

const ids = await v2.create("res.partner", [{ name: "Acme" }, { name: "Globex" }]); // result: [41, 42]
const one = await v2.create_one("res.partner", { name: "Initech" });               // result: 43
await v2.write("res.partner", ids.result!, { comment: "via v2" });
await v2.remove("res.partner", ids.result!);

await v2.call_method("account.move", "action_post", { ids: [7] });                  // record method
await v2.call_method("res.partner", "name_search", { name: "Acm", limit: 5 });
HelperOdoo methodkwargs sentresult
v2.search(model, { domain, offset?, limit?, order?, context? })searchsame keysnumber[]
v2.search_read(model, { domain?, fields?, offset?, limit?, order?, context? }?)search_readsame keysrecords
v2.search_count(model, { domain, limit?, context? })search_countsame keysnumber
v2.read(model, ids, { fields?, load?, context? }?)readids + keysrecords
v2.fields_get(model, { allfields?, attributes?, context? }?)fields_getsame keysobject by field
v2.create(model, vals | vals[], { context? }?)createvals_list (always an array)number[]
v2.create_one(model, vals, { context? }?)createvals_list: [vals]number
v2.write(model, ids, vals, { context? }?)writeids, valstrue
v2.remove(model, ids, { context? }?)unlinkidstrue
v2.call_method(model, method, kwargs?)methodkwargs as givenmethod's return
v2.version(url?) / v2.is_supported(url?)—POST /v2/odoo/version{ version_info, version } / boolean (cached)

The option keys are sent to Odoo exactly as written (domain, vals_list, allfields, ids, …) — never camelCased. Unset options are omitted, so Odoo's own defaults apply. In call_method, pass ids only for record methods. Every v2 helper takes a trailing opts?: { id?, timeoutSecs?, signal? }.

Auxiliary endpoints

import { version, about, license, metrics } from "@terrakernel/odxproxy-client-js";

const v   = await version("https://erp.example.com"); // envelope; url is required
const a   = await about();      // envelope: { build, version }
const lic = await license();    // OdxLicenseInfo — NOT an envelope
const txt = await metrics();    // Prometheus text (string)

license() resolves to a flat OdxLicenseInfo ({ licensee, valid_until, is_valid }), not a JSON-RPC envelope — the proxy's /_/license endpoint emits a flat object. about, license, and metrics need no API key.

Typed errors

Every failure — proxy-level (non-2xx) and Odoo logic errors (a 200 carrying an error body) — is thrown as an OdxError subclass. Branch with instanceof. See the full error catalog.

ClassProxy codeMeaning
AuthError-32000Missing or wrong x-api-key.
InvalidActionError-32001Action not in the allowlist.
MissingFnNameError-32002call_method without fn_name.
OdooTimeoutError-32003Upstream Odoo timed out (also on client abort ceiling).
OdooConnectError-32004Network failure reaching Odoo.
InternalProxyError-32005Internal proxy error.
LicenseError0 (HTTP 403)Proxy integrity/license check failed.
OdooLogicErrorOdoo's code (HTTP 200)Odoo business error (validation, access rights).
Json2UnavailableError-32006v2: no JSON-2 on that Odoo (18 or older), or the database isn't selectable on that host.
InvalidRequestError-32007v2: invalid model/method name, or db/api_key not valid in an HTTP header.

When Odoo's HTTP status comes back as the code — always on v2 — the SDK throws a subclass of OdooLogicError: OdooAuthError (401, the Odoo key — not the proxy's AuthError), OdooAccessError (403), OdooNotFoundError (404), OdooConflictError (409, retryable), OdooValidationError (422), or OdooServerError (5xx). Existing instanceof OdooLogicError checks still catch them; err.odooErrorName gives Odoo's exception class from data.name.

import { search_read, AuthError, OdooLogicError, OdooTimeoutError, OdxError }
  from "@terrakernel/odxproxy-client-js";

try {
  const res = await search_read("res.partner", [[]], { context: { tz: "UTC" } });
  console.log(res.result);
} catch (err) {
  if (err instanceof AuthError)        { /* bad x-api-key — reauth */ }
  else if (err instanceof OdooLogicError)   { /* Odoo validation / access error */ }
  else if (err instanceof OdooTimeoutError) { /* retry with backoff */ }
  else if (err instanceof OdxError)    { console.error(err.code, err.message, err.httpStatus); }
  else throw err; // not from this SDK (e.g. a caller-initiated AbortError)
}

On every OdxError, code is the JSON-RPC code (the values above) — not the HTTP status, which is on httpStatus. A JSON-RPC error can arrive with HTTP 200 (OdooLogicError); the client inspects the envelope's error field for you, but never infer success from the HTTP status alone if you drop to raw fetch.


Looking for another language? See the SDK overview — every client mirrors this same wire protocol.

On this page