Code Agency
7 min read

Odoo's external API surface: JSON-2, REST and when to build your own

Every integration starts with the same question: how do we talk to Odoo? The honest trade-offs between the stock JSON-2 API, community REST modules and a purpose-built gateway — and the one question that picks the right door per consumer.

Every Odoo project reaches the moment where something outside the ERP needs the data inside it. A storefront wants stock levels. A warehouse system wants pickings. The accountant's tool wants invoices, a partner wants order status, someone in marketing wants contacts synced somewhere. And every one of those conversations starts with the same deceptively simple question: how do we talk to Odoo?

There are three honest answers: the JSON-2 endpoint that ships in the box, a REST layer from the community, or a door you build yourself. All three are correct somewhere. Most of the integration pain we get called in to fix traces back to picking the right-sounding one instead of the right one — usually eighteen months earlier, usually by accident.

Door one: JSON-2, the API that ships in the box

Since Odoo 19, the stock external API is JSON-2: POST /json/2/<model>/<method>, a bearer API key in the Authorization header, the target database in X-Odoo-Database, and the method's keyword arguments as a plain JSON body. No envelope, no session ritual — and every database now serves its own interactive API reference at /doc, listing the models, fields and methods actually installed there, ready to test against real data. That last part quietly fixes the oldest annoyance of integrating with Odoo: documentation that describes a vanilla database you don't run.

What the endpoint wraps hasn't changed, though. It is not "an API" in the contract sense at all — it's the ORM, over HTTP. Any model, any public method, the same search, read and write your server-side code uses:

the stock surface — the ORM, over HTTP
import requests
 
BASE_URL = "https://erp.example.be/json/2"
headers = {
    "Authorization": f"bearer {API_KEY}",
    "X-Odoo-Database": "production",
}
 
orders = requests.post(
    f"{BASE_URL}/sale.order/search_read",
    headers=headers,
    json={
        "domain": [["state", "=", "sale"]],
        "fields": ["name", "amount_total", "partner_id"],
        "limit": 20,
    },
).json()

A note before anyone copies an older tutorial: the XML-RPC and JSON-RPC endpoints this surface replaces — /xmlrpc, /xmlrpc/2 and /jsonrpc, with execute_kw's four-positional-argument ritual — still answer today, but they are formally deprecated and scheduled for removal in Odoo 22 (fall 2028) and Odoo Online 21.1 (winter 2027). New integrations shouldn't touch them, and existing ones now have a migration deadline with an actual date on it.

As a capability, JSON-2 is glorious. Complete coverage of the entire ERP on day one, zero modules installed, zero code deployed, self-documenting per database. For an internal script, a one-off data migration, a nightly sync you run and own on both ends, we reach for it without hesitation and so should you.

But a nicer surface doesn't change the nature of the thing, because ergonomics were never the problem. We spelled this out in the NestJS layer post and it bears compressing to three lines here:

  • The model is the interface. Consumers read sale.order, the actual table with the actual fields. Rename one, or let a version migration move it, and integrations break at runtime — at night, in someone else's system.
  • The permissions are ORM permissions. An API key acts as its user, everywhere that user can act. "This consumer may read order status and nothing else" is not expressible — unless you build it yourself from groups and record rules around a dedicated integration user, which is exactly what we do for every direct-RPC consumer we allow to exist.
  • The load is ERP load. Every call runs on the same workers that serve your sales team, and those workers have less headroom than you think.

None of that matters when both ends of the wire are yours. All of it matters the moment they aren't.

Door two: REST from the community

The OCA's rest-framework repository is the community's answer to "we'd like an actual contract, please." The current generation is the fastapi addon, which mounts real FastAPI apps inside Odoo — Pydantic schemas, dependency injection, an OpenAPI spec generated for free:

OCA fastapi — the contract is explicit, and it lives in the ERP
from typing import Annotated
 
from fastapi import APIRouter, Depends
from pydantic import BaseModel
 
from odoo.api import Environment
from odoo.addons.fastapi.dependencies import odoo_env
 
class OrderStatus(BaseModel):
    name: str
    state: str
    carrier_tracking_ref: str | None = None
 
router = APIRouter()
 
@router.get("/orders/{name}", response_model=OrderStatus)
def order_status(
    name: str,
    env: Annotated[Environment, Depends(odoo_env)],
) -> OrderStatus:
    order = env["sale.order"].search([("name", "=", name)], limit=1)
    return OrderStatus(
        name=order.name,
        state=order.state,
        carrier_tracking_ref=order.carrier_tracking_ref or None,
    )

This is a genuine step up from raw RPC. The consumer no longer sees your data model; it sees OrderStatus, a shape you chose, validated at the edge, documented by the framework, reviewed in a pull request. Rename whatever you like inside the ERP — the endpoint keeps its promise until you decide otherwise. For a team that's fluent in Python and has one or two known consumers, this is a perfectly good place to stop.

Two costs, stated plainly, because this is the door whose costs are quietest. First: the contract now lives inside the ERP, so it rides the ERP's release cycle. The addon is one more module in your upgrade path, and shipping a contract change means deploying Odoo — your API's release cadence is welded to your ERP's, which is precisely the coupling a contract was supposed to dissolve. Second: the requests still execute on Odoo workers. A REST shape in front of the ORM changes who absorbs schema drift; it changes nothing about who absorbs traffic.

Door three: your own front door

The third answer is a separate service in front of the ERP that owns the public contract outright — consumers talk to it, and only it talks to Odoo. Ours is a NestJS layer, and we've written the full argument up already, so here we'll only say what the other two doors structurally cannot give you:

  • A release cycle that isn't the ERP's. Version the API, ship it Tuesday, upgrade Odoo in August. Neither event notices the other.
  • Caching and shaping outside the ERP. The storefront's ten-thousand catalog reads an hour hit the gateway's cache, not an Odoo worker.
  • Per-consumer scoping as a first-class feature. Rate limits, scopes and keys per client, without bending record rules into an authorization system.
  • One language across the stack. For the React and TypeScript products we build, the contract is TypeScript end to end — DTOs on the server, generated types in the client.

The cost is equally structural: it's a real service. Built, deployed, monitored, on-call for. For a single trusted integration it is straightforwardly overkill, and we say so to clients who arrive asking for one.

How we decide

Strip away the technology and the decision is one question: count your consumers, and ask who owns them.

  • Zero external consumers — scripts, migrations, syncs you run yourself: stock JSON-2, no ceremony. Building more here is procrastination with an architecture diagram.
  • One or two server-side consumers you control both ends of: stock JSON-2 behind a dedicated, least-privilege integration user — or the OCA fastapi addon the day you want the contract explicit rather than implicit.
  • Any consumer you don't control — a mobile app frozen in someone's release cycle, a partner's system, anything public: a gateway. The defining feature of a consumer you don't control is that it can't move when Odoo does, so something you control has to stand in between and absorb the difference.
  • Browsers never call /json/2. Not with any key, not "just for the demo." A credential in a browser is public, and the ORM does not understand the concept of a public audience.

The pattern underneath is the one that decides most architecture questions honestly: an interface is a promise about who absorbs change. /json/2 makes the consumer absorb everything. A REST module moves the promise into the ERP. A gateway makes the promise independently of the ERP — which is why it's the only door that scales past consumers you can list from memory. Pick the cheapest door that keeps the promise you actually need to make; just know which promise you're making before the eighteen months start counting.

Want us to publish something specific?

Tell us what you'd like to read and we'll add it to our writing queue.

Get the next one in your inbox

New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.