ODXProxy

Getting Started

Deploy ODXProxy with Docker Compose, verify the license, and make your first JSON-RPC call.

This walkthrough takes you from nothing to a working search_read against a real Odoo database. It should take about ten minutes.

By the end you will have:

  • ODXProxy running in Docker with a mounted config directory and a signed license,
  • a verified /_/license and /_/about response,
  • a first POST /api/odoo/execute call returning Odoo records.

Before you begin

You need four things:

#WhatWhere it comes from
1Docker with Compose v2docker compose version
2A signed license.jsonIssued to you — see Add the license file
3A reachable Odoo instanceIts base URL and database name
4An Odoo user API keyOdoo → Preferences → Account Security → New API Key

Two different secrets

ODXProxy uses two unrelated keys, and conflating them is the single most common setup mistake:

  • PROXY_API_KEY — the proxy's own key. Clients send it as the x-api-key header. You choose this value.
  • odoo_instance.api_key — the Odoo user's API key, generated inside Odoo. It travels in the request body, per request.

Deploy the proxy

Create the config directory

ODXProxy reads a dotenv file and a signed license file. Keep both in one directory that you mount into the container:

config
license.json
mkdir -p ~/odxproxy/config ~/odxproxy/logs

The container runs as a non-root user (UID 65532) from a distroless image, so the log directory must be writable by that UID:

sudo chown -R 65532:65532 ~/odxproxy/logs

Write the config file

Create ~/odxproxy/config/config. This is a plain dotenv file — one KEY=value per line, # for comments:

~/odxproxy/config/config
# Address and port for the proxy server to listen on
LISTEN_ADDR=0.0.0.0:6600

# The API key your gateway/clients must send as `x-api-key` on every /api/* request
PROXY_API_KEY=change-me-to-a-long-random-string

# Log level. `info` is the default; use `debug` while you are setting things up.
RUST_LOG=info,TheODXProxy=info

# Default timeout for the upstream Odoo call, in seconds.
# Overridable per request via the `x-request-timeout` header.
DEFAULT_ODOO_TIMEOUT_SECS=600

# Where rolling daily JSON logs are written (container path)
LOG_PATH=/var/log/odxproxy

# Path to the signed license file (container path)
LICENSE_KEY=/usr/share/odxproxy/config/license.json

# Clustering heartbeat — leave disabled unless you run multiple instances
HOSTNAME=YOUR_ODX_HOSTNAME_FOR_HEARTBEAT
REDIS_URL=YOUR_REDIS_URL_IF_USING_HEARTBEAT
HEARTBEAT=false

Paths are container paths

LOG_PATH and LICENSE_KEY are resolved inside the container, so they must match the mount targets in your Compose file — not the paths on your host. If you run the binary directly instead of in Docker, use host paths and pass the config file as the first CLI argument: odxproxy /etc/odxproxy/config.

Generate a strong PROXY_API_KEY rather than inventing one:

openssl rand -base64 36

Add the license file

ODXProxy validates a signed license on every request, against a trusted time source — not the machine's system clock, so a skewed or rolled-back host clock will neither extend nor break a license. Put the file you were issued at ~/odxproxy/config/license.json:

~/odxproxy/config/license.json
{
  "licensee": "Example Industries Ltd",
  "valid_until": "2030-01-31",
  "signature": "<the signature from your issued license — do not edit>"
}

The three fields are the licensee name, the expiry date, and a signature over them. Do not edit any of them — any change invalidates the signature, and the proxy will then answer every /api/* request with HTTP 403 and JSON-RPC code 0.

Write the Compose file

docker-compose.yaml
services:
  odxproxy:
    image: terrakernel/odxproxy:latest
    ports:
      - "6600:6600"

    volumes:
      - /home/user/odxproxy/config:/usr/share/odxproxy/config:ro
      - /home/user/odxproxy/logs:/var/log/odxproxy

    restart: unless-stopped

Three things to keep aligned:

  • The published port must match LISTEN_ADDR in your config (6600 here — the proxy's own default is 3000).
  • The config directory is mounted read-only (:ro); the proxy only ever reads it.
  • The log volume must be writable — that is what the chown in step 1 was for.

Replace /home/user/odxproxy with the absolute path you created. Compose does not expand ~ in volume paths.

Start it

docker compose up -d
docker compose logs -f odxproxy

A healthy start logs the listen address and the licensee. If the container exits immediately, read the logs first — then see Troubleshooting.

Verify the deployment

Two endpoints answer without an x-api-key, which makes them the right first check — they separate "is the proxy up and licensed" from "is my key right".

curl -s http://localhost:6600/_/license
{
  "licensee": "Example Industries Ltd",
  "valid_until": "2030-01-31",
  "is_valid": true
}

is_valid: false means every /api/* call will fail with HTTP 403 and code 0. Fix the license before going further.

Confirm the running build:

curl -s http://localhost:6600/_/about
{
  "jsonrpc": "2.0",
  "id": "about",
  "result": { "build": "<build ULID>", "version": "0.1.0" }
}

Every response also carries an x-server: odxproxy <version> <build> header, so you can identify the build from any call:

curl -si http://localhost:6600/_/about | grep -i x-server

Prometheus metrics are at GET /_/metrics.

Keep the ops endpoints internal

/_/license, /_/about, and /_/metrics are unauthenticated by design. Don't expose them to the public internet — terminate them at your gateway, or restrict them by network.

Collect your Odoo credentials

ODXProxy does not store Odoo credentials. They travel in each request body, which is what lets one deployment serve many Odoo instances. You need four values:

FieldWhat it isWhere to find it
urlBase URL of the Odoo servere.g. https://erp.example.com — no trailing path
dbDatabase nameOdoo's database selector, or your --db-filter
user_idInteger Odoo uidSettings → Users & Companies → Users → open the user; the id is in the URL
api_keyThat user's API keyPreferences → Account Security → New API Key

Check reachability before you involve credentials at all. /api/odoo/version asks Odoo for its public version banner, so it needs the proxy key but no Odoo credentials:

curl -s -X POST http://localhost:6600/api/odoo/version \
  -H "x-api-key: $PROXY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "id": "01J8ZQ4M7N9K2P5R8T1V3W6X9Y", "url": "https://erp.example.com" }'

If that returns Odoo's version_info, the network path from proxy to Odoo works and your x-api-key is correct. Anything else is a connectivity or key problem, not a credentials problem.

Your first request

Everything else goes through POST /api/odoo/execute — one request shape, nine actions:

curl -s -X POST http://localhost:6600/api/odoo/execute \
  -H "x-api-key: $PROXY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "01J8ZQ4M7N9K2P5R8T1V3W6X9Y",
    "action": "search_read",
    "model_id": "res.partner",
    "params": [[["is_company", "=", true]]],
    "keyword": { "fields": ["id", "name", "email"], "limit": 10 },
    "odoo_instance": {
      "url": "https://erp.example.com",
      "db": "prod",
      "user_id": 2,
      "api_key": "<ODOO_USER_API_KEY>"
    }
  }'

The body maps onto Odoo's execute_kw almost one-to-one:

  • id — a client-generated string (UUID or ULID). It is echoed back so you can correlate responses.
  • action — one of the nine allowed actions. Anything else is rejected with -32001.
  • model_id — the Odoo model, e.g. res.partner.
  • params — a JSON array of positional args (defaults to []). For search_read the first element is the domain, hence the doubled brackets.
  • keyword — a JSON object of kwargs (defaults to {}): fields, limit, offset, order, context, …
  • odoo_instance — the per-request target, from the table above.

A success looks like this:

{
  "jsonrpc": "2.0",
  "id": "01J8ZQ4M7N9K2P5R8T1V3W6X9Y",
  "result": [
    { "id": 14, "name": "Acme Corp", "email": "hello@acme.example" },
    { "id": 27, "name": "Globex", "email": "contact@globex.example" }
  ]
}

Always check the response in two steps

HTTP 200 can still be an error

Odoo's own logic errors — validation failures, access-right denials — come back as HTTP 200 with a populated error object. Only proxy-layer failures use non-200 status codes.

So every client must do this, in order:

  1. Is the HTTP status 200? If not, it is a proxy-level failure — surface the JSON-RPC error (see the error catalog).
  2. Is error present in the body? If yes, it is an Odoo logic error — surface it. Otherwise read result.
status=$(curl -s -o body.json -w '%{http_code}' \
  -X POST http://localhost:6600/api/odoo/execute \
  -H "x-api-key: $PROXY_API_KEY" \
  -H "Content-Type: application/json" \
  -d @request.json)

jq --arg s "$status" \
  'if $s != "200" or .error
   then "error \(.error.code): \(.error.message)"
   else .result end' body.json

The official SDKs do both checks for you and raise typed exceptions, so you only ever handle result.

Calling anything else

Only the nine actions are directly callable. Any other Odoo method goes through call_method with a non-empty fn_name:

{
  "id": "01J8ZQ4M7N9K2P5R8T1V3W6X9Z",
  "action": "call_method",
  "model_id": "sale.order",
  "fn_name": "action_confirm",
  "params": [[42]],
  "keyword": {},
  "odoo_instance": { "url": "…", "db": "…", "user_id": 2, "api_key": "…" }
}

Omitting fn_name on a call_method returns HTTP 400 with code -32002.

Configuration reference

Every setting is read from the dotenv file at startup.

VariableDefaultNotes
LISTEN_ADDR0.0.0.0:3000Address and port the proxy binds.
PROXY_API_KEY(required)The x-api-key value clients must send.
DEFAULT_ODOO_TIMEOUT_SECS15Upstream Odoo timeout. Overridable per request with x-request-timeout.
LICENSE_KEYlicense.jsonPath to the signed license file.
LOG_PATH/var/log/odxproxyRolling daily JSON logs.
RUST_LOGinfotracing filter, e.g. info,TheODXProxy=debug.
HEARTBEATfalsetrue enables the Redis clustering heartbeat.
REDIS_URL(optional)Required when HEARTBEAT=true.
HOSTNAMElocalhostThis node's key in the Redis heartbeat.

Per request, x-request-timeout (integer seconds) overrides DEFAULT_ODOO_TIMEOUT_SECS; missing, non-numeric, or 0 falls back to the default. Send Accept-Encoding: gzip, br, or deflate to get compressed responses.

Troubleshooting

SymptomCauseFix
Container exits at startupThe proxy refuses to run as root (UID 0)Don't override the image's user — the distroless image already runs as UID 65532.
Logs empty / permission deniedLog volume not writable by UID 65532chown -R 65532:65532 the host log directory.
403 + code 0 on every callLicense expired, unreadable, or editedCheck GET /_/license; verify LICENSE_KEY points at the container path.
401 + code -32000Missing or wrong x-api-keyCompare byte-for-byte with PROXY_API_KEY; watch for trailing newlines in shell variables.
400 + code -32001action isn't one of the nineUse call_method + fn_name for anything else.
400 + code -32002call_method without fn_nameSupply a non-empty fn_name.
502 + code -32004Proxy can't reach OdooTest with /api/odoo/version; check DNS and egress from the container.
504 + code -32003Odoo call exceeded the timeoutRaise DEFAULT_ODOO_TIMEOUT_SECS, or send x-request-timeout.
200 with an error bodyAn Odoo logic error, not a proxy failureRead error.data — it carries Odoo's payload.

The full list is in the error catalog.

Next steps

  • The nine actions — what each one takes in params and keyword.
  • Error codes — the full catalog and the two-step check.
  • API reference — every endpoint, generated from the OpenAPI spec.
  • SDKs — Python, JavaScript, Java, PHP, Swift, and .NET clients that wrap all of the above.

On this page