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 addInitialize 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_urltrims a trailing slash; defaults tohttps://gateway.odxproxy.io.default_timeout_secsis sent asx-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.
| Action | Function | params shape | result |
|---|---|---|---|
search_count | search_count | [[domain]] | number |
search | search | [[domain]] | number[] (IDs) |
read | read | [[ids], [fields]] | records |
fields_get | fields_get | (model, keyword) | field metadata |
search_read | search_read | [[domain]] | records |
create | create | [{ fields }] | number (new ID) |
write | write (alias update) | [[ids], { fields }] | boolean |
unlink | remove | [[ids]] | boolean |
call_method | call_method | method args array | method 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 });| Helper | Odoo method | kwargs sent | result |
|---|---|---|---|
v2.search(model, { domain, offset?, limit?, order?, context? }) | search | same keys | number[] |
v2.search_read(model, { domain?, fields?, offset?, limit?, order?, context? }?) | search_read | same keys | records |
v2.search_count(model, { domain, limit?, context? }) | search_count | same keys | number |
v2.read(model, ids, { fields?, load?, context? }?) | read | ids + keys | records |
v2.fields_get(model, { allfields?, attributes?, context? }?) | fields_get | same keys | object by field |
v2.create(model, vals | vals[], { context? }?) | create | vals_list (always an array) | number[] |
v2.create_one(model, vals, { context? }?) | create | vals_list: [vals] | number |
v2.write(model, ids, vals, { context? }?) | write | ids, vals | true |
v2.remove(model, ids, { context? }?) | unlink | ids | true |
v2.call_method(model, method, kwargs?) | method | kwargs as given | method'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.
| Class | Proxy code | Meaning |
|---|---|---|
AuthError | -32000 | Missing or wrong x-api-key. |
InvalidActionError | -32001 | Action not in the allowlist. |
MissingFnNameError | -32002 | call_method without fn_name. |
OdooTimeoutError | -32003 | Upstream Odoo timed out (also on client abort ceiling). |
OdooConnectError | -32004 | Network failure reaching Odoo. |
InternalProxyError | -32005 | Internal proxy error. |
LicenseError | 0 (HTTP 403) | Proxy integrity/license check failed. |
OdooLogicError | Odoo's code (HTTP 200) | Odoo business error (validation, access rights). |
Json2UnavailableError | -32006 | v2: no JSON-2 on that Odoo (18 or older), or the database isn't selectable on that host. |
InvalidRequestError | -32007 | v2: 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.