# Odoo's New /json/2 API: What Changed and How to Migrate Before the Deadline

> Odoo's /json/2 API replaces password authentication with bearer API keys and finally returns real HTTP error codes. What changed, how to rewrite calls, and the removal dates.

**Date:** 2026-06-22
**Source:** <https://trobz.com/insights/odoo-json2-api-migration/>

---



This post covers what the new `/json/2` API is, how it differs from what came before, how to generate API keys, and how to rewrite the most common integration patterns.

---

## Why the Old API Is Going Away

The XML-RPC and JSON-RPC endpoints have been Odoo's external API since the early days. They work, but they carry years of design decisions that were reasonable at the time and are now friction.

Two problems stand out in practice.

The first is authentication. Both APIs authenticate with a username and password. For machine-to-machine integrations, that means storing a user's credentials somewhere, rotating them when the user changes their password, and accepting that a compromised credential gives full user-level access. API keys are a standard solution to this problem, but the old API never adopted them.

The second is error signaling. Both the XML-RPC and JSON-RPC endpoints return **HTTP 200 on errors**. The error information is buried inside the response body. That breaks every standard HTTP monitoring tool, every retry library, and every developer's intuition about what a 200 means. You have to parse every response to find out whether the call succeeded.

`/json/2` fixes both.

---

## How /json/2 Works

The structure is straightforward: `POST /json/2/<model>/<method>`.

The model is the technical model name (`res.partner`, `sale.order`). The method is what you are calling (`search`, `read`, `search_read`, `create`, `write`, `unlink`, or any custom method exposed externally). The request body is a JSON object containing the method's arguments by name.

### Request Headers

| Header            | Required    | Value                                                                   |
| ----------------- | ----------- | ----------------------------------------------------------------------- |
| `Authorization`   | Yes         | `bearer <API_KEY>`                                                      |
| `Content-Type`    | Yes         | `application/json` (charset recommended)                                |
| `X-Odoo-Database` | When needed | Database name — only required when the domain serves multiple databases |
| `User-Agent`      | Recommended | Your software name                                                      |

### Request Body

```json
{
  "context": { "lang": "en_US" },
  "domain": [
    ["name", "ilike", "%deco%"],
    ["is_company", "=", true]
  ],
  "fields": ["name"]
}
```

Arguments are named. There is no positional argument mode — you must use the exact parameter names defined on the Odoo method (`ids`, `domain`, `fields`, `vals`, `vals_list`, etc.).

### Response

Success returns **HTTP 200** with the method's return value as JSON.

Errors return a **4xx or 5xx** status with a JSON error object:

```json
{
  "name": "werkzeug.exceptions.Unauthorized",
  "message": "Invalid apikey",
  "arguments": ["Invalid apikey", 401],
  "context": {},
  "debug": "Traceback (most recent call last): ..."
}
```

This is a meaningful change. With proper status codes, your monitoring stack can tell the difference between a working integration and a failing one without inspecting every response body.

### Transaction Behavior

Each call to `/json/2` runs in its own SQL transaction. Success commits it; errors discard it. You cannot chain multiple calls into a single transaction through the external API.

---

## Getting an API Key

API keys are per-user and time-limited. Regular users can generate keys with a maximum lifetime of **90 days**. Administrators can create keys with no expiry.

**To generate a key manually:**

1. Go to `Preferences > Account Security`
2. Click **New API Key**
3. Enter a description (use something that identifies where the key is used — not just "API key")
4. Set an expiration date — keep it short for interactive use
5. Click **Generate Key**

The key is a 160-bit random value. It is shown exactly once. Copy it immediately and store it outside your codebase — a secrets manager, environment variable, or vault. If you lose it, you have to revoke it and generate a new one.

> ⚠️ Keys last a maximum of 90 days for standard users. This means integrations need a key rotation process. Either automate rotation programmatically (see below) or set a calendar reminder before each key expires.

### Programmatic Key Generation

If you need to rotate keys without manual UI steps:

```python
import requests

API_KEY = ...  # current key, from a secure location

res = requests.post(
    "https://mycompany.example.com/json/2/res.users.apikeys/generate",
    headers={"Authorization": f"bearer {API_KEY}"},
    json={
        "name": "my-integration-service",
        "expiration_date": "2026-09-22",
        "scope": None,
    },
)
res.raise_for_status()
new_key = res.json()  # store this securely, then retire the old key
```

---

## A Working Integration in Python

```python
import requests

BASE_URL = "https://mycompany.example.com/json/2"
API_KEY = ...  # from a secure location

headers = {
    "Authorization": f"bearer {API_KEY}",
    "X-Odoo-Database": "mycompany",
    "User-Agent": "my-integration " + requests.utils.default_user_agent(),
}

# search
res = requests.post(
    f"{BASE_URL}/res.partner/search",
    headers=headers,
    json={
        "context": {"lang": "en_US"},
        "domain": [["name", "ilike", "%deco%"], ["is_company", "=", True]],
    },
)
res.raise_for_status()
ids = res.json()

# read
res = requests.post(
    f"{BASE_URL}/res.partner/read",
    headers=headers,
    json={
        "ids": ids,
        "context": {"lang": "en_US"},
        "fields": ["name", "email"],
    },
)
res.raise_for_status()
records = res.json()
```

No XML parsing. No RPC envelope. No checking the response status code buried in a JSON payload. The HTTP layer carries the semantics.

---

## What the Migration Actually Looks Like

Here is a side-by-side of the same operation — reading partners — in the old XML-RPC style versus `/json/2`.

**Old (XML-RPC, Python `xmlrpc.client`):**

```python
import xmlrpc.client

url = "https://mycompany.example.com"
db = "mycompany"
username = "admin"
password = "admin_password"

common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common")
uid = common.authenticate(db, username, password, {})

models = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object")
partners = models.execute_kw(
    db, uid, password,
    "res.partner", "search_read",
    [[["name", "ilike", "%deco%"]]],
    {"fields": ["name"], "limit": 10}
)
```

**New (/json/2, `requests`):**

```python
import requests

partners = requests.post(
    "https://mycompany.example.com/json/2/res.partner/search_read",
    headers={"Authorization": "bearer <API_KEY>", "X-Odoo-Database": "mycompany"},
    json={"domain": [["name", "ilike", "%deco%"]], "fields": ["name"], "limit": 10},
).json()
```

The old version needs two round-trips (authenticate, then call). The new version needs one. The old version passes credentials on every call. The new version sends a key that never exposes the user's password.

### What to Watch for When Rewriting

**Named arguments only.** XML-RPC allowed positional arguments. `/json/2` does not. If your call uses `execute_kw` with a positional list, you need to check the method signature and name the arguments explicitly. `write` takes `vals`, not a positional dict. `create` takes `vals_list` for bulk creation.

**No `db` or `common` service equivalent.** The old `db` and `common` RPC services (list databases, get server version, authenticate) are not part of `/json/2`. Version info moved to `GET /web/version`. User ID retrieval is done via `res.users/context_get` with no `ids` — the API extracts the user from the key automatically.

**`/xmlrpc/2/object` only, not `/xmlrpc/2/db`.** The `/json/2` endpoint replaces the `object` service. Database management via RPC is not migrated — and that is intentional.

---

## Key Differences at a Glance

|                     | XML-RPC / JSON-RPC          | /json/2                    |
| ------------------- | --------------------------- | -------------------------- |
| Authentication      | Username + password         | Bearer API key             |
| Key lifetime        | As long as the user exists  | 90 days max (regular user) |
| Error signaling     | Always HTTP 200, check body | HTTP 4xx / 5xx             |
| Argument style      | Positional or named         | Named only                 |
| URL structure       | `/xmlrpc/2/object` fixed    | `/json/2/<model>/<method>` |
| Transaction scope   | Varies                      | One transaction per call   |
| Big integer support | Limited (XML-RPC)           | Full                       |
| Dynamic docs        | None                        | `/doc` per database        |
| Removal             | Odoo 22 / Online 21.1       | Current and maintained     |

---

## Deprecation Timeline

| Platform           | Deprecated | Removed                   |
| ------------------ | ---------- | ------------------------- |
| Odoo Online (SaaS) | Odoo 19    | Online 21.1 (winter 2027) |
| Odoo.sh            | Odoo 19    | Odoo 22 (fall 2028)       |
| On-premise         | Odoo 19    | Odoo 22 (fall 2028)       |

If you are on Odoo Online, that is roughly 18 months. On Odoo.sh or on-prem, you have until fall 2028. In both cases, the sensible time to migrate is before your next major upgrade, not the week before the deadline.

> ⚠️ Note: The deprecation affects `/xmlrpc`, `/xmlrpc/2`, and `/jsonrpc`. It does **not** affect the internal `@route(type='jsonrpc')` controllers used by Odoo's own web client — those are a separate system and are not being removed.

---

## Exploring the API on Your Own Instance

Every Odoo 19 instance exposes dynamic documentation at `/doc`. This shows you the exact models, methods, fields, and access groups available in your specific database, with runnable examples. If you are building an integration and are not sure which method to call or what arguments it takes, that is the place to start.

