Odoo

Odoo external API: XML-RPC and JSON-RPC via authenticate and execute_kw, the new /json/2 bearer endpoint, sale.order, account.move, product.product, stock.quant.

Odoo is an open source ERP and business application suite, sold as Odoo Online (SaaS, odoo.com), Odoo.sh (managed hosting) and self-hosted Community or Enterprise. Its external API is unlike every other platform in this set: there are no hand-written REST resources, no per-object endpoints and no curated schema. Instead the API is a thin remote-procedure-call wrapper over Odoo's own ORM, so any model and any method that exists in the database is callable, including modules a customer installed last week. That makes it extraordinarily capable and slightly unnerving: the contract is the customer's database, not a published specification. Two things have changed recently and matter a great deal for a new build: Odoo 19 introduced a modern bearer-token /json/2 endpoint, and the classic /xmlrpc/2 and /jsonrpc endpoints are now formally deprecated with removal dates published.

At a glance

What it is

Odoo SA is a Belgian company. The product is a modular suite (Sales, Invoicing, Inventory, Manufacturing, Purchase, CRM, Website, Point of Sale and many more) on a shared ORM and a shared Postgres database. Community edition is LGPL open source; Enterprise adds proprietary modules and support. There are tens of thousands of community modules on the Odoo Apps store, any of which can add or change models and fields.

That modularity is the defining fact for a connector. There is no fixed schema. A field like carrier_tracking_ref exists on stock.picking only if the stock_delivery module is installed. A customer's sale.order may have twenty custom fields added by their implementation partner. Before doing anything else a connector should call fields_get on every model it touches and adapt, rather than assume.

The version matters too. Odoo ships a major release each autumn and supports roughly three at a time, and self-hosted customers sit on whatever version they last migrated to. A connector in the field will meet Odoo 14 through 19 simultaneously, with different field names and different endpoints.

For commerce, the relevant models are:

  • sale.order and sale.order.line: the sales order and its lines.
  • account.move and account.move.line: everything in the ledger, including customer invoices and credit notes, discriminated by move_type.
  • product.template (the product) and product.product (the variant). Variants are first class in Odoo and a connector should normally key on product.product.
  • stock.quant: quantity of one product in one location. This is the inventory table.
  • stock.picking and stock.move: the transfer (delivery order, receipt, internal move) and its lines.
  • res.partner: customers, suppliers, contacts and addresses, all one model.

API access

Self-hosted and Odoo.sh: nothing to apply for. You need a database name, a user with the right access rights, and an API key.

Odoo Online: the documentation is explicit and this is the commercial gate to check first.

Warning

"Access to data via the external API is only available on Custom Odoo pricing plans. Access to the external API is not available on One App Free or Standard plans." A prospective customer on the Standard plan cannot be integrated at all without an upgrade. Establish the plan before scoping the work.

Deprecation, as published in the master documentation:

Both the XML-RPC and JSON-RPC APIs at endpoints /xmlrpc, /xmlrpc/2 and /jsonrpc are deprecated.

The timetable given:

So the RPC endpoints a connector would naturally reach for still work, and will keep working on self-hosted installs until 2028, but the /json/2 endpoint introduced in 19.0 is where new work belongs. A connector serving a mixed fleet needs both transports behind one interface.

Discovering the schema. Odoo 19 exposes a per-database documentation page: "The actual models, fields and methods available are specific to every database and can be consulted on their /doc page." On older versions, fields_get on each model is the equivalent.

Official client libraries. Odoo does not publish server SDKs. The documentation's examples use each language's standard XML-RPC client (Python's xmlrpc.client, Ruby's XMLRPC::Client, PHP's ripcord, Java's Apache XML-RPC, Go's kolo/xmlrpc). For /json/2 it is an ordinary HTTP client.

Authentication

The new way: /json/2 with a bearer API key, Odoo 19 and later

Headers, as documented:

The URL carries the model and the method: POST /json/2/<model>/<method>. The body is a JSON object with ids (an array of record ids, empty or omitted for @api.model methods), an optional context, and the method's own parameters as named keys.

POST /json/2/res.partner/search_read HTTP/1.1
Host: mycompany.example.com
Authorization: bearer <api key>
Content-Type: application/json; charset=utf-8
User-Agent: mysoftware python-requests/2.25.1

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

Success is HTTP 200 with the method's return value serialised directly as the body, with no envelope:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

[
   {"id": 25, "name": "Deco Addict"}
]

Errors are 4xx or 5xx with a JSON error object carrying name (the fully qualified Python exception class), message, arguments (all exception arguments) and context. An invalid key produces werkzeug.exceptions.Unauthorized: 401 Unauthorized: Invalid apikey.

There is no user id and no password in this scheme. The documentation notes that "the JSON-2 API uses a different authentication scheme where neither the user ID nor the password are used", and that you can recover the acting user's own id by calling res.users/context_get with no ids, since the user is derived from the key.

API keys can be created and revoked over the API, which is unusual and useful for rotation:

curl https://mycompany.example.com/json/2/res.users.apikeys/generate \
    -X POST \
    --oauth2-bearer "$API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
        "key": "'"$API_KEY"'",
        "scope": null,
        "name": "Some service",
        "expiration_date": "2026-05-19"
    }' \
    --silent \
    --fail

The generated key is returned as a string and "cannot be retrieved otherwise", so capture it at creation. A user can generate up to 10 keys programmatically by default, configurable through the system parameter base.programmatic_api_keys_limit; exceeding it returns HTTP 422 Unprocessable Content. res.users.apikeys.revoke() revokes, and the documented rotation practice is to authenticate with the new key when revoking the old one. Expiration dates are validated against the maximum API key duration allowed by the user's roles.

The classic way: XML-RPC, all versions

Two endpoints. /xmlrpc/2/common needs no authentication and offers version(), login(db, login, password) and authenticate(db, login, password, user_agent_env). authenticate returns the uid.

common = xmlrpc.client.ServerProxy('{}/xmlrpc/2/common'.format(url))
common.version()
uid = common.authenticate(db, username, api_key, {})

/xmlrpc/2/object calls model methods through execute_kw, whose parameters are, in order: the database name, the user id, the password or API key, the model name, the method name, a positional argument list, and an optional keyword argument dict.

models = xmlrpc.client.ServerProxy('{}/xmlrpc/2/object'.format(url))
models.execute_kw(db, uid, api_key, 'res.partner', 'search',
                  [[['is_company', '=', True]]])

There is no session and no token to refresh: the credential travels on every call. For API keys, the documentation's instruction is simply to use the key in place of the password.

On Odoo 19, the version() function is replaced by a plain endpoint:

GET /web/version HTTP/1.1

HTTP/1.1 200 OK
Content-Type: application/json

{"version_info": [19, 0, 0, "final", 0, ""], "version": "19.0"}

Call it first: it needs no credentials, tells you the major version, and therefore tells you which transport and which field names to use.

Multi-account. Per Odoo customer you store a base URL, a database name, a username and an API key (classic) or just a base URL, optional database name and API key (/json/2). There is no tenant header and no organisation id: the database is the tenant, and one server can host many. Because there is no token expiry in the classic scheme, credential rotation is entirely on you.

Objects we can read

Every read is a model method. The four that matter:

  • search(domain, offset=, limit=, order=) returns record ids.
  • read(ids, fields=) returns those records' fields.
  • search_read(domain, fields=, offset=, limit=, order=) does both in one round trip and is what you should use.
  • search_count(domain) returns the total, for paging.
  • fields_get(attributes=['string','type','help','selection','relation']) returns the model's schema, which is how you discover a customer's custom fields.

Domains are Odoo's filter language: a list of [field, operator, value] triples, with '&', '|' and '!' as prefix operators when combining. Operators include =, !=, >, >=, <, <=, like, ilike, in, not in, child_of. Dotted paths traverse relations, for example ['order_id.partner_id.country_id.code', '=', 'IN'].

Pagination is offset and limit on search and search_read, with order as an SQL-like sort string. There is no cursor. Odoo's documentation notes the default is to return every matching id, which on a real database is a mistake.

Incremental sync uses write_date, the ORM's automatic last-modified timestamp present on every model, together with create_date:

{
  "domain": [["write_date", ">", "2026-09-21 10:00:00"]],
  "fields": ["name", "partner_id", "state", "amount_total", "write_date"],
  "limit": 500,
  "offset": 0,
  "order": "write_date asc, id asc"
}

Timestamps are UTC and formatted YYYY-MM-DD HH:MM:SS. Many-to-one fields come back as [id, display_name] pairs, one-to-many and many-to-many as arrays of ids. Missing or false values come back as false, not null, which catches out strict deserialisers.

Note

Deletes are invisible to a write_date sweep, because deleted rows are gone. Two mitigations: most Odoo business records are archived (active = false) rather than deleted, and archived records are excluded from searches by default, so add ['active', 'in', [true, false]] to the domain to see them; and periodically reconcile the id set with search over an empty domain to detect real deletions.

Orders

sale.order. Fields verified from the Odoo 17.0 source:

Note that there are only four state values and none of them describe fulfilment. Whether an order shipped is a property of its stock.picking records, and whether it was billed is invoice_status. A unified status has to be computed from all three.

{
  "id": 42,
  "name": "S00042",
  "partner_id": [17, "Deco Addict"],
  "partner_shipping_id": [19, "Deco Addict, Warehouse"],
  "state": "sale",
  "date_order": "2026-09-20 11:04:12",
  "commitment_date": "2026-09-24 08:00:00",
  "client_order_ref": "AMZ-402-1234567-8901234",
  "currency_id": [2, "EUR"],
  "amount_untaxed": 2250.0,
  "amount_tax": 405.0,
  "amount_total": 2655.0,
  "amount_to_invoice": 2655.0,
  "invoice_status": "to invoice",
  "invoice_ids": [],
  "order_line": [88, 89],
  "write_date": "2026-09-20 11:05:03"
}

Order items

sale.order.line, read with a domain of [['order_id', 'in', order_ids]]. Verified fields:

Two traps. display_type marks section headers and notes, which appear in order_line and must be filtered out before summing. is_downpayment marks deposit lines, which are money but not product.

Products and listings

product.template is the product and product.product is the sellable variant. Fields verified on product.template:

product.product carries the same fields (most of them related through the template) plus its own default_code and barcode, so variants can have their own SKU and barcode. Key on product.product, because stock and order lines both reference the variant, not the template.

There is no listing object. list_price is one price; per-customer and per-channel pricing is product.pricelist with product.pricelist.item rules, a separate model pair.

Inventory

stock.quant is the inventory table: one row per product, location, lot and owner combination. Fields verified from source:

A company-wide figure per product is a read_group over stock.quant grouped by product_id, filtered to internal locations. Odoo also exposes computed qty_available, virtual_available, incoming_qty and outgoing_qty on the product itself, which are convenient but are computed per request and are slower than reading quants directly.

Location matters and is hierarchical. stock.location has a usage field distinguishing internal stock from supplier, customer, inventory (the adjustment counterpart), production, transit and view (a grouping node with no stock). Only internal locations are your stock. Filter on it or you will count goods that have already left.

Shipments and tracking

stock.picking is a transfer, with stock.move lines. Verified fields on stock.picking:

Carrier fields come from the stock_delivery module and only exist if it is installed:

There are no tracking events. Odoo records the reference and a link, not a scan history.

Returns and cancellations

Two separate things, as in most ERPs.

Financial: account.move with move_type of out_refund is a customer credit note. It carries reversed_entry_id pointing at the original invoice when it was created as a reversal.

Physical: a return is another stock.picking, created from the outbound one by Odoo's return wizard, moving stock from the customer location back to an internal location. Identify it by picking_type_id being an incoming type with the outbound picking's name in origin.

Cancellation: state of cancel on the order, picking or move. Records are not deleted.

Payments and settlements

account.move is everything in the ledger. move_type discriminates, and the values are verified from source: entry, out_invoice (customer invoice), out_refund (customer credit note), in_invoice (vendor bill), in_refund (vendor credit note), out_receipt (sales receipt), in_receipt (purchase receipt).

Verified fields on account.move:

account.payment records money received. There is no settlement object; a marketplace payout would be a bank statement line (account.bank.statement.line) reconciled against invoices.

Customers

res.partner is customers, suppliers, companies and individual contacts in one model, with parent_id giving the hierarchy and type distinguishing contact, invoice, delivery, other and private addresses. is_company separates organisations from people. customer_rank and supplier_rank are counters Odoo uses to decide whether a partner behaves as a customer, a supplier or both.

Fields include name, email, phone, mobile, vat, street, street2, city, zip, state_id, country_id, website, ref, lang and active. All of that is unmasked PII.

Locations

Three models, and they are not interchangeable.

  • stock.warehouse: a physical warehouse, with a short code and a set of locations and operation types beneath it.
  • stock.location: the hierarchical location tree, with usage separating internal stock from virtual counterparts, and complete_name giving the full path.
  • res.company: the legal entity, in a multi-company database.

Writing back: sales orders, invoices and stock adjustments

Writes are create, write and unlink on the model, plus business methods such as action_confirm. All go through the same transport.

Create takes a dict (or a list of dicts for a batch) and returns the new id (or ids):

order_id = models.execute_kw(db, uid, api_key, 'sale.order', 'create', [{
    'partner_id': 17,
    'client_order_ref': 'AMZ-402-1234567-8901234',
    'date_order': '2026-09-22 09:00:00',
    'order_line': [
        (0, 0, {
            'product_id': 604,
            'product_uom_qty': 5,
            'price_unit': 450.0,
        }),
    ],
}])

The (0, 0, {...}) triple is Odoo's one-to-many command syntax and is the single most confusing thing about writing to Odoo. The commands are: (0, 0, values) create a new linked record, (1, id, values) update an existing one, (2, id, 0) delete it, (3, id, 0) unlink without deleting, (4, id, 0) link an existing record, (5, 0, 0) unlink all, (6, 0, [ids]) replace the whole set. Many-to-many fields use the same vocabulary, and (6, 0, [ids]) is the usual form for setting taxes.

Confirming. A created order is in draft. Call the business method to move it forward rather than writing state directly, because the method runs the side effects (reserving stock, creating pickings):

models.execute_kw(db, uid, api_key, 'sale.order', 'action_confirm', [[order_id]])

Similarly _create_invoices on the order creates the invoice, and action_post on account.move posts it. Writing state by hand produces a record that looks right and has no stock moves behind it.

Invoices can be created directly as account.move with move_type of out_invoice and invoice_line_ids built with the same (0, 0, {...}) commands, then posted with action_post. For a marketplace order that is already paid, the usual pattern is order, confirm, create invoice, post, then register a payment.

Stock adjustments are the one place Odoo is better than every other system in this set, because it accepts an absolute target rather than a delta. Write the counted quantity to stock.quant:

quant_ids = models.execute_kw(db, uid, api_key, 'stock.quant', 'search', [[
    ['product_id', '=', 604],
    ['location_id', '=', 8],
]])
models.execute_kw(db, uid, api_key, 'stock.quant', 'write', [quant_ids, {
    'inventory_quantity_auto_apply': 42,
}])

inventory_quantity_auto_apply sets the counted quantity and applies the adjustment immediately, generating the stock move against the inventory-adjustment location. The two-step alternative is to write inventory_quantity and then call action_apply_inventory on the quants, which is what you want when several lines should be applied together.

Warning

inventory_quantity_auto_apply is restricted to the stock.group_stock_manager group, and the source raises "Only a stock manager can validate an inventory adjustment." if the acting user lacks it. The API user for a connector that writes stock must be a stock manager, which is a broader grant than most integrations want. Agree it explicitly during onboarding.

If no quant exists yet for that product and location, write has nothing to write to. Create the quant instead, with product_id, location_id and inventory_quantity_auto_apply in the same create call.

Prices and catalogue. write on product.template or product.product for list_price, standard_price, default_code, barcode and active. Archiving is {'active': False}, not unlink, and is what you want: unlinking a product referenced by any order will fail on a foreign key.

Batching. create and write both accept lists, so a hundred records is one round trip. write applies the same values to every id in the list, so heterogeneous updates need one call per distinct value set. There is no documented request size limit; the limits are your server's.

Idempotency. There is none. Repeating a create creates a second record. The guard is to put your external id in a field you can search: client_order_ref on sale.order, ref on account.move and res.partner, or a custom field. Search before creating, or better, maintain the mapping in your own store and treat Odoo as write-only for creation.

Webhooks and notifications

The external API has no webhook subscription mechanism, and none appears in the reference.

What exists instead is automation rules (base.automation, historically "automated actions"), a server-side feature where an administrator configures a trigger (on create, on write, on a field change, on a time condition) and an action. From Odoo 17 the action list includes sending a webhook to a URL. That is a real push mechanism, but it is configured inside each customer's database, by an administrator or by a module you ship, not through an API call from your side. It is also unsigned by default: there is no documented HMAC scheme, so the receiver must rely on a secret in the URL or a header the rule is configured to send.

For a connector serving many Odoo customers, the practical answer is polling, and Odoo polls cheaply because write_date is indexed and present on every model:

  • sale.order and sale.order.line with write_date greater than the high-water mark, every 2 to 5 minutes.
  • stock.quant at the same cadence if stock matters, or stock.move if you want movements rather than balances.
  • account.move filtered to move_type in ['out_invoice', 'out_refund'] every 5 to 15 minutes.
  • stock.picking every 5 minutes for fulfilment state.
  • product.product and res.partner hourly.

Use search_read with a field list so you are not pulling every column, order by write_date asc, id asc so paging is stable, and overlap the window by a minute to absorb clock skew.

Rate limits and pagination

No published rate limit. Odoo does not document a request quota for the external API on any version read here, and for self-hosted installs there is no throttle at all beyond what the customer's reverse proxy imposes. Odoo Online will have operational protections, but they are not published.

The limits that actually bite are resource limits on the customer's own server. A search_read with no limit over a model with a million rows will try to serialise all of it. Odoo workers have configurable memory and CPU time limits (limit_memory_hard, limit_time_cpu, limit_time_real) and a request that exceeds them is killed mid-flight, which surfaces as a connection reset rather than a clean error.

Practical rules:

  • Always pass limit. 500 to 1000 records per call is a reasonable ceiling for a model with a modest field list.
  • Always pass an explicit fields list to search_read. Omitting it returns every field, including computed and binary ones, and on account.move or product.template that is enormous.
  • Never read binary fields (image_1920, signature, attachments) in a sweep. Fetch them separately and only when needed.
  • Prefer search_read to search plus read: half the round trips.
  • Use read_group for aggregates instead of pulling rows and summing client-side.
  • Be conservative with concurrency. An Odoo deployment typically runs a small number of worker processes, and four parallel heavy reads can starve the customer's own users.

Pagination is offset plus limit with a stable order. Deep offsets get slower because Postgres still has to walk the rows, so for a backfill prefer keyset paging: order by id asc and filter ['id', '>', last_seen_id].

Mapping to the unified model

Gaps and open questions

  • The schema is per database. Every field above was verified against Odoo 17.0 source, but a given customer may be on 14 or 19, may not have the stock_delivery module, and will very likely have custom fields. Call fields_get on every model at connector start and adapt, and on Odoo 19 read the database's own /doc page.
  • The transport is changing. /xmlrpc/2 and /jsonrpc are deprecated, with the common and object services scheduled for removal in Odoo 22 (autumn 2028) and Odoo Online 21.1 (winter 2027). New work should target /json/2, but /json/2 only exists from 19.0, so a connector serving existing customers needs both. Budget for a transport abstraction from day one.
  • Odoo Online plan gating. The external API is unavailable on One App Free and Standard plans. This is a sales blocker, not an engineering one, and it should be checked before any technical scoping.
  • No rate limits are published. Nothing is documented for Odoo Online, and self-hosted has none. That means there is also no signal telling you when you are being too aggressive; you will find out by degrading the customer's own users. Be conservative.
  • No webhooks from the API. Automation rules can call a webhook but are configured inside each database and have no documented signature scheme. Whether a connector can create those rules through the API (they are records on base.automation) and whether doing so is acceptable to customers was not established.
  • Deletes are invisible. A write_date sweep never sees a deleted row. Archiving covers most cases because Odoo archives rather than deletes, but a periodic id reconciliation is still needed.
  • Access rights are the real contract. Every call runs as the API key's user and is filtered by that user's record rules, including multi-company rules. Two users can read the same model and get different rows. Establish exactly which groups the integration user has, and remember that writing stock adjustments needs stock.group_stock_manager.
  • Business methods versus field writes. Writing state directly bypasses the side effects that the corresponding action method performs. Which methods must be called rather than emulated (action_confirm, _create_invoices, action_post, action_apply_inventory, button_validate on a picking) is not enumerated anywhere central and has to be learned per workflow.
  • Money and units. Quantities are in the line's own unit of measure, not a canonical one, and a connector that ignores product_uom will silently mix dozens with units. Currency is per record through currency_id, and multi-currency databases mix them freely.

Sources

  • Odoo External API reference, master: the /json/2 endpoint, its headers and body shape, the success and error formats, API key generation and revocation through res.users.apikeys, the base.programmatic_api_keys_limit parameter and the HTTP 422 behaviour, the Odoo Online Custom-plan restriction, the /web/version endpoint, and the XML-RPC and JSON-RPC deprecation notice with its removal timetable
  • Odoo External API reference, 17.0: the /xmlrpc/2/common and /xmlrpc/2/object endpoints, authenticate, the execute_kw parameter list, search, search_read, search_count, fields_get, domain syntax, and offset and limit pagination
  • odoo/odoo at branch 17.0, source read for every field name in this page: addons/sale/models/sale_order.py (including the SALE_ORDER_STATE selection), addons/sale/models/sale_order_line.py, addons/account/models/account_move.py (including the state and move_type selections), addons/product/models/product_template.py, addons/stock/models/stock_quant.py (including inventory_quantity_auto_apply, action_apply_inventory and the stock-manager restriction in _apply_inventory), addons/stock/models/stock_picking.py, and addons/stock_delivery/models/stock_picking.py for carrier_id, carrier_tracking_ref, carrier_tracking_url and carrier_price