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
/_/licenseand/_/aboutresponse, - a first
POST /api/odoo/executecall returning Odoo records.
Before you begin
You need four things:
| # | What | Where it comes from |
|---|---|---|
| 1 | Docker with Compose v2 | docker compose version |
| 2 | A signed license.json | Issued to you — see Add the license file |
| 3 | A reachable Odoo instance | Its base URL and database name |
| 4 | An Odoo user API key | Odoo → 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 thex-api-keyheader. 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:
mkdir -p ~/odxproxy/config ~/odxproxy/logsThe 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/logsWrite the config file
Create ~/odxproxy/config/config. This is a plain dotenv file — one KEY=value per
line, # for comments:
# 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=falsePaths 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 36Add 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:
{
"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
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-stoppedThree things to keep aligned:
- The published port must match
LISTEN_ADDRin your config (6600here — the proxy's own default is3000). - The config directory is mounted read-only (
:ro); the proxy only ever reads it. - The log volume must be writable — that is what the
chownin 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 odxproxyA 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-serverPrometheus 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:
| Field | What it is | Where to find it |
|---|---|---|
url | Base URL of the Odoo server | e.g. https://erp.example.com — no trailing path |
db | Database name | Odoo's database selector, or your --db-filter |
user_id | Integer Odoo uid | Settings → Users & Companies → Users → open the user; the id is in the URL |
api_key | That user's API key | Preferences → 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[]). Forsearch_readthe 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:
- Is the HTTP status 200? If not, it is a proxy-level failure — surface the
JSON-RPC
error(see the error catalog). - Is
errorpresent in the body? If yes, it is an Odoo logic error — surface it. Otherwise readresult.
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.jsonThe 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.
| Variable | Default | Notes |
|---|---|---|
LISTEN_ADDR | 0.0.0.0:3000 | Address and port the proxy binds. |
PROXY_API_KEY | (required) | The x-api-key value clients must send. |
DEFAULT_ODOO_TIMEOUT_SECS | 15 | Upstream Odoo timeout. Overridable per request with x-request-timeout. |
LICENSE_KEY | license.json | Path to the signed license file. |
LOG_PATH | /var/log/odxproxy | Rolling daily JSON logs. |
RUST_LOG | info | tracing filter, e.g. info,TheODXProxy=debug. |
HEARTBEAT | false | true enables the Redis clustering heartbeat. |
REDIS_URL | (optional) | Required when HEARTBEAT=true. |
HOSTNAME | localhost | This 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
| Symptom | Cause | Fix |
|---|---|---|
| Container exits at startup | The 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 denied | Log volume not writable by UID 65532 | chown -R 65532:65532 the host log directory. |
403 + code 0 on every call | License expired, unreadable, or edited | Check GET /_/license; verify LICENSE_KEY points at the container path. |
401 + code -32000 | Missing or wrong x-api-key | Compare byte-for-byte with PROXY_API_KEY; watch for trailing newlines in shell variables. |
400 + code -32001 | action isn't one of the nine | Use call_method + fn_name for anything else. |
400 + code -32002 | call_method without fn_name | Supply a non-empty fn_name. |
502 + code -32004 | Proxy can't reach Odoo | Test with /api/odoo/version; check DNS and egress from the container. |
504 + code -32003 | Odoo call exceeded the timeout | Raise DEFAULT_ODOO_TIMEOUT_SECS, or send x-request-timeout. |
200 with an error body | An Odoo logic error, not a proxy failure | Read 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
paramsandkeyword. - 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.