Odoo exposes a stable JSON-RPC external API that lets any system - a custom connector, an e-commerce platform, a logistics provider, a CRM - read and write data directly. It has been in place since Odoo 8 and works the same way across all versions still in active use (16, 17, 18).
This guide covers everything you need to connect a real external system: how the protocol works, the four endpoints you will actually use, the authentication options, common read/write patterns, and the gotchas that every integrator runs into the first time.
How the Protocol Works#
Odoo's external API uses JSON-RPC 2.0 over HTTP POST. Every call goes to a single URL with a JSON body that identifies the method and passes arguments. The response is a JSON object with either a result key (success) or an error key (failure).
There is no REST API in the traditional sense - no per-resource URLs, no GET for fetching a record. Everything goes through the same JSON-RPC mechanism. This surprises developers coming from REST-first ecosystems, but it is consistent and predictable once understood.
The Four Endpoints#
You only need to know four URL paths:
/web/webclient/version_info - returns the Odoo server version without authentication. Useful for health checks and to confirm the instance is reachable.
/web/dataset/call_kw - the main endpoint. Every model operation (search, read, write, create, unlink) goes through here. Requires an authenticated session.
/web/session/authenticate - creates a session and returns a session cookie plus the uid (user ID) you need for subsequent calls.
/jsonrpc - an older endpoint that accepts the same calls as call_kw but via a different envelope. Some libraries use it; call_kw is preferred for new code.
Authentication#
Session-Based Authentication#
The most common approach: call /web/session/authenticate with the database name, username, and password. Odoo returns a session cookie that must be sent with every subsequent request.
POST /web/session/authenticate
{
"jsonrpc": "2.0",
"method": "call",
"params": {
"db": "mycompany",
"login": "admin",
"password": "adminpassword"
}
}The response includes uid - the integer user ID. Save it; you need it for all call_kw requests. The response also sets a session_id cookie. Include that cookie in every subsequent HTTP request.
API Key Authentication (Odoo 14+)#
Since Odoo 14, users can generate API keys in their profile settings. Instead of a session cookie, pass the API key in the Authorization header:
Authorization: Bearer <your-api-key>Combined with the JSON-RPC body, this gives you stateless authentication - no session management, no cookie handling. This is the recommended approach for server-to-server integrations where managing cookie state is inconvenient.
With API key auth, you still need the uid in your call_kw calls. Fetch it once from /web/dataset/call_kw with res.users, read, or derive it from /web/session/get_session_info.
Reading Data#
Searching for Records#
search_read is the most useful single method - it combines a domain filter with field selection and returns records in one call:
POST /web/dataset/call_kw
{
"jsonrpc": "2.0",
"method": "call",
"params": {
"model": "sale.order",
"method": "search_read",
"args": [],
"kwargs": {
"domain": [["state", "=", "sale"], ["partner_id.country_id.code", "=", "US"]],
"fields": ["name", "partner_id", "amount_total", "date_order"],
"limit": 100,
"offset": 0,
"order": "date_order desc"
}
}
}The domain is a list of [field_name, operator, value] triples. Multiple conditions are implicitly AND'd. Use "&", "|", and "!" prefixes for explicit boolean logic.
Following Relations#
The partner_id.country_id.code path in the domain above shows that Odoo resolves dotted field paths in domains - you can filter on related fields without joining anything manually.
In fields, Many2one fields return [id, display_name] by default. One2many and Many2many fields return a list of IDs. To get the nested record data, either:
- Make a second
search_readcall on the related model with those IDs. - Use
read_groupif you need aggregation. - Accept the IDs and resolve them lazily in your integration code.
There is no built-in way to fetch nested One2many records in a single search_read call. Plan for N+1 fetch patterns if you need line items.
Counting Records#
For counts without fetching data, use search_count:
"method": "search_count",
"args": [[["state", "=", "draft"]]],
"kwargs": {}Writing Data#
Creating Records#
"method": "create",
"args": [{"name": "New Order", "partner_id": 42}],
"kwargs": {}Returns the integer ID of the created record. Mandatory fields that have no default value must be provided or Odoo raises a ValidationError.
Updating Records#
"method": "write",
"args": [[101, 102, 103], {"priority": "1"}],
"kwargs": {}The first argument is a list of record IDs to update. The second is the dict of values to set. Returns True on success.
Many2many and One2many Commands#
Setting relation fields requires Odoo's command syntax - plain IDs are not enough:
[4, id, 0]- link an existing record without creating.[3, id, 0]- unlink (remove the relation but do not delete the record).[5, 0, 0]- clear the entire relation (remove all).[0, 0, {values}]- create a new related record and link it.[1, id, {values}]- update a linked record in place.[2, id, 0]- unlink and delete the related record.
Example - replace the tag list on a partner:
"method": "write",
"args": [[partner_id], {"category_id": [[5, 0, 0], [4, 7, 0], [4, 12, 0]]}],
"kwargs": {}Deleting Records#
"method": "unlink",
"args": [[101, 102]],
"kwargs": {}Be careful: Odoo's unlink can fail with a database constraint error if other records reference the ones you are deleting. Check for related records first, or handle the error and present it to the user.
Common Gotchas#
Access Rights Are Enforced Everywhere#
The API user needs the same rights as a regular Odoo user for every operation. If a user cannot see a field in the UI, search_read on that field will silently return False rather than raising an error. If a user cannot write to a model, the write call raises AccessError.
Test integrations with an account that has only the permissions the integration actually needs - not admin. Production integrations running as admin will hide access issues until something breaks in a surprising way.
Computed Fields May Not Be Stored#
Some computed fields are not stored in the database; they are computed on the fly when you access them through the UI or ORM. search_read on non-stored computed fields works for reading, but you cannot filter on them in a domain (Odoo cannot do a database WHERE on a field that is not in the table). Attempting to filter on a non-stored computed field raises a ValueError. Check the field definition or ask the team which computed fields are stored.
Date Handling#
Odoo stores all dates in UTC without timezone information. When reading date or datetime fields, the values come back as ISO 8601 strings in UTC. When writing dates, send UTC strings in YYYY-MM-DD HH:MM:SS format (not ISO 8601 with timezone offset). Odoo will reject timezone-aware strings.
Record Rules Reduce Your Result Set Silently#
Record rules (row-level security) filter the records a user can see. If you search_read and get fewer records than expected, it may be because the API user does not have access to all records matching your domain. The search_count and search_read results will simply not include restricted records - there is no error or indication that records were filtered.
Batching Is Your Responsibility#
The external API has no built-in batching endpoint. If you need to create 10,000 records, you call create 10,000 times (or write a loop that calls create with a single dict each time). For high-volume imports, consider using Odoo's built-in import tool or a database-level migration script instead of the external API.
For reads, use search_read with limit and offset to paginate. There is a server-side limit on the number of records that can be returned in a single call (configurable, but usually 80–200 in default deployments).
Connection Pooling and Timeouts#
Each JSON-RPC call is a fresh HTTP request. Reuse HTTP connections (keep-alive) for throughput. Long-running operations - like computing a complex report - can time out at the load balancer before Odoo finishes. Keep individual calls short; break long workloads into smaller operations.
When to Use the External API vs. Other Approaches#
The external API is the right choice when:
- You are connecting a third-party system that cannot have an Odoo module installed.
- You need near-real-time push from an external system into Odoo.
- Your team controls the external system and can maintain the integration code.
Consider alternatives when:
- High-volume data migration: use direct database access or Odoo's built-in import (CSV/XLSX) for one-time loads. The external API is too slow for millions of records.
- Triggering internal Odoo business logic: if the operation logically belongs inside Odoo (e.g., confirming a sale order, posting a journal entry), define a custom method on the model and call it via the API rather than replicating the logic externally.
- Realtime webhooks outbound from Odoo: Odoo's automated actions can POST to external URLs on record create/update. This is simpler than polling from outside.
A Minimal Working Example#
Here is the full flow in pseudocode to confirm a sale order from an external system:
1. POST /web/session/authenticate → get uid + session cookie
2. POST /web/dataset/call_kw
model: sale.order
method: search_read
domain: [["name", "=", "S00042"]]
fields: ["id", "state"]
→ get record id, confirm state is "draft"
3. POST /web/dataset/call_kw
model: sale.order
method: action_confirm
args: [[record_id]]
kwargs: {}
→ confirms the order, triggering all Odoo business logicCalling action_confirm is correct here - not write with state: "sale". Writing directly to state bypasses the method chain that computes delivery, invoicing, stock moves, and any custom hooks. Always use the model's public methods for state transitions.
ERPeek can tell you which methods are available on any Odoo model, what their signatures look like, and whether they have been overridden by custom modules in your specific codebase - in plain language, without reading source code by hand. If you are building a connector against a customised Odoo instance, the contact page is the fastest way to explore what is actually there.

