What ODXProxy Solves: Six Odoo Integration Problems
Credential sprawl, unbounded method access, timeouts, error handling, multi-instance routing, and visibility — what ODXProxy fixes, and what it deliberately doesn't.
ODXProxy Team · Aug 4, 2026 · 10 min read

ODXProxy is a reverse proxy that sits in front of one or more Odoo instances and exposes a single authenticated JSON-RPC 2.0 endpoint. That sentence describes the shape of the thing but not the point of it, so this article does the opposite: six concrete problems that show up once real services start calling Odoo, what ODXProxy does about each, and — just as usefully — the problems it does not solve, so you know what you still have to build. It is an early product (v0.1.0), and this page tries to be honest about exactly where that line falls.
1. Credential sprawl across every service
Odoo's External API authenticates each call with a database name, a user id, and that user's API key.
Nothing enforces where those live, so the default outcome is that they end up everywhere: the
storefront's environment, the mobile backend's secret manager, an automation platform's connection
settings, a colleague's .env, a CI job. Rotating the key means finding every copy. Auditing "what
touched the ERP" means correlating logs from systems that don't share a format.
ODXProxy replaces that with one credential your services actually hold — the proxy's key, sent as
the x-api-key header — and moves the Odoo connection into the request body, where it can be supplied
by whatever holds it legitimately:
curl -X POST https://your-proxy.example.com/api/odoo/execute \
-H "Content-Type: application/json" \
-H "x-api-key: $ODX_PROXY_KEY" \
-d '{
"id": "01J9Z8K3QJ7Y5T2N6V4W8X0ABC",
"action": "search_read",
"model_id": "res.partner",
"params": [[["is_company", "=", true]]],
"keyword": { "fields": ["name", "email"], "limit": 20 },
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": "<the Odoo user's API key>"
}
}'x-api-key authenticates your caller to the proxy. odoo_instance.api_key authenticates the Odoo call itself. A 401 with code -32000 means the first one is wrong; an Odoo access error returned with HTTP 200 means the second one is. The full breakdown is in how to authenticate to the Odoo API.2. Any leaked credential can call anything
This is the problem people underestimate. Odoo's execute_kw is not a fixed set of endpoints — it
invokes methods on models. A credential that can read customers can also, protocol-wise, attempt
to post journal entries, confirm orders, or call any other public method on any model the user can
reach. Your webhook receiver only needs search_read, but nothing in the protocol says so.
ODXProxy enforces an action allowlist. Exactly nine actions are directly callable, and anything
else is rejected with HTTP 400 and code -32001 before a request is ever made to Odoo:
| Action | Meaning |
|---|---|
search_count | Count records matching a domain. |
search | Return IDs matching a domain. |
read | Read fields for a list of IDs. |
fields_get | Describe a model's fields. |
search_read | Combined search + read. |
create | Create record(s). |
write | Update record(s) by ID. |
unlink | Delete record(s) by ID. |
call_method | Invoke a named model method — requires a non-empty fn_name. |
Business methods that aren't CRUD go through call_method, and the method name has to be stated
explicitly in fn_name:
{
"id": "01J9Z8K3QJ7Y5T2N6V4W8X0DEF",
"action": "call_method",
"model_id": "account.move",
"fn_name": "action_post",
"params": [[142]],
"keyword": {},
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": "<the Odoo user's API key>"
}
}Omit fn_name and the request fails with -32002. The proxy will not infer a method name — an
important property, because "guess what the caller meant" is not a behaviour you want on the path to
your accounting records. More on the design in
the Odoo API gateway guide, and the mechanics of
calling custom Odoo methods with call_method.
3. A slow Odoo takes your application down with it
An Odoo instance mid-report, mid-import, or mid-cron can take a very long time to answer. A client with no timeout waits, holds its connection, and — under load — exhausts its own pool waiting on someone else's ERP. The failure propagates outward from the slowest query.
The proxy applies a timeout to the upstream Odoo call, defaulting to 15 seconds, overridable per
request with an integer x-request-timeout header (missing, non-numeric, or 0 falls back to the
default):
curl -X POST https://your-proxy.example.com/api/odoo/execute \
-H "x-api-key: $ODX_PROXY_KEY" \
-H "x-request-timeout: 60" \
-H "Content-Type: application/json" \
-d @bulk-export.jsonJust as importantly, it tells you which failure you hit. "Odoo was too slow" and "Odoo was
unreachable" arrive as different codes — -32003 with HTTP 504 and -32004 with HTTP 502 — so a
client can retry the first and page someone about the second. Distinguishing those at the gateway is
what stops every service from writing its own guesswork; the wider failure taxonomy is in
fixing Odoo 502 and 504 gateway errors.
4. Every client reinvents the same fragile error handling
Odoo's error surface is genuinely awkward to consume. The most-hit tripwire: a logic error comes
back as HTTP 200. Access denied, a validation constraint, a missing record — all of it arrives with
a success status and an error object in the body. A client that checks response.ok and moves on
treats an access denial as an empty result set and silently syncs nothing.
ODXProxy does not paper over this, because it can't without lying about what Odoo said. What it does
is make the envelope uniform, so one handler covers every call. Every /api/* response is the same
JSON-RPC 2.0 shape, with result and error mutually exclusive:
def call(session, payload):
r = session.post(f"{PROXY_URL}/api/odoo/execute",
json=payload,
headers={"x-api-key": PROXY_KEY})
# Step 1 — proxy-layer failure (auth, allowlist, timeout, unreachable Odoo)
if r.status_code != 200:
err = r.json().get("error", {})
raise ProxyError(err.get("code"), err.get("message"))
body = r.json()
# Step 2 — a 200 can STILL carry an Odoo logic error
if "error" in body and body["error"] is not None:
err = body["error"]
raise OdooLogicError(err.get("code"), err.get("message"), err.get("data"))
return body["result"]The proxy-layer codes are a closed set, which is what makes that first branch worth writing once:
| HTTP | code | Meaning |
|---|---|---|
| 401 | -32000 | Missing or wrong x-api-key |
| 400 | -32001 | action not in the allowlist |
| 400 | -32002 | call_method with no fn_name |
| 504 | -32003 | Upstream Odoo call timed out |
| 502 | -32004 | Could not reach the Odoo instance |
| 500 | -32005 | Internal proxy error |
| 403 | 0 | Proxy license expired or invalid |
except OdooTimeoutError rather than comparing integers. See the Python SDK — or the Odoo API error handling guide if you're implementing the client yourself.5. Staging, production, and one instance per client
Anyone running Odoo for more than one entity hits this: an agency with an instance per client, a group with a database per country, or just the ordinary staging/production pair. Point integration code at a different instance and you're editing per-environment configuration in every service that talks to Odoo — and every new tenant multiplies the work.
Because the target instance travels in the request body, one proxy deployment fronts all of them.
The proxy URL and x-api-key never change; odoo_instance does. No upstream block per tenant, no
redeploy to onboard one.
There's also a credential-free reachability check. POST /api/odoo/version asks a target Odoo for its
public version banner — it needs the proxy key but no Odoo credentials at all:
curl -X POST https://your-proxy.example.com/api/odoo/version \
-H "Content-Type: application/json" \
-H "x-api-key: $ODX_PROXY_KEY" \
-d '{ "id": "ping-1", "url": "https://erp.example.com" }'That separates "can the proxy reach this instance at all" from "are these Odoo credentials right",
which are otherwise easy to confuse while onboarding a new tenant. Note that the db you name is a
call parameter, not something inferred from the hostname — a distinction that matters on multi-database
servers and is covered in
Odoo dbfilter: serving multiple databases behind one domain.
6. No idea what your integrations are doing to the ERP
Odoo's own logs tell you a request arrived; they don't tell you that your fulfilment worker's calls started timing out an hour ago. With every integration calling Odoo directly, there is no single place where that question has an answer.
A proxy is that place. ODXProxy exposes Prometheus metrics at GET /_/metrics (no key required),
including counters for successful upstream calls and failed ones labelled by JSON-RPC error code, plus
standard request/response counters and histograms:
curl -s https://your-proxy.example.com/_/metrics | grep odoo_Every response also carries an x-server header identifying the running build, and GET /_/about
returns the same version and build id as a JSON-RPC envelope — enough to tell which build produced a
response you're arguing about.
What ODXProxy does not solve
Being clear about the boundary is more useful than a longer feature list. As of v0.1.0:
- It is not Odoo's permission system. Record rules, model ACLs, and multi-company rules still apply to every call, and they are what determines which records come back. A gateway allowlist limits which actions run; it does not widen or narrow what a given Odoo user may see. If your API results look short, the cause is usually Odoo-side — see why Odoo returns fewer records over the API.
- It does not do rate limiting or quotas. Timeouts ship today; per-client rate limits and quotas are roadmap, not current behaviour. If you need to protect Odoo from a runaway client right now, that belongs in the layer in front.
- It does not cache. Every call reaches Odoo. Repeated identical
search_reads are repeated work, and deciding what is safe to cache is application knowledge the proxy doesn't have. - It does not push. Odoo does not notify you when a record changes, and a proxy in the request path can't invent that. Event-driven flows still need Odoo-side automation — the options are compared in Odoo webhooks and the API.
- It does not replace your web reverse proxy. nginx or Traefik in front of the Odoo web client
solves a different problem — TLS, forwarded headers,
proxy_mode. Run both; they're orthogonal. See the Odoo proxy_mode configuration guide. - It does not authenticate your end users. The proxy authenticates services. Mapping a logged-in human to an Odoo user, and everything that comes with it — sessions, SSO, and the fact that API keys bypass Odoo's two-factor authentication — remains your application's job.
- It will not make a slow Odoo fast. It bounds how long you wait and tells you clearly what happened. Query and worker tuning is still Odoo-side work.
Should you run one or build one?
You can build this. It is a service that compares a key, checks an action against a list, forwards
execute_kw to the instance named in the body, and normalizes the response. The parts that are easy
to get subtly wrong are the ones above: the two-step error check, per-request timeouts that
distinguish 504 from 502, and never leaking to a caller which of the two keys was the wrong one.
ODXProxy is that gateway ready-made, and deliberately narrow: one authenticated entry point, nine allowlisted actions, per-request instance routing, bounded upstream calls, and metrics. If you want the exact request and response contract, it's in the API reference; if you'd rather not write the envelope by hand, start with the Python SDK.