How Odoo's API Works
Odoo exposes three API protocols: XML-RPC, JSON-RPC, and a REST API (added in Odoo 17). All three access the same underlying ORM layer β the same models, fields, and methods you'd use in a Python module.
Deprecation warning: Odoo has announced that both XML-RPC (/xmlrpc, /xmlrpc/2) and JSON-RPC (/jsonrpc) are scheduled for removal in Odoo 20 (targeted for fall 2026). Odoo 19 introduced a replacement called the JSON-2 API, which uses bearer token authentication and standard HTTP conventions. If you're starting a new integration today targeting Odoo 17+, it's worth checking the JSON-2 API docs before committing to XML-RPC. For integrations that need to support older versions (14β18), XML-RPC remains the most compatible choice.
The key thing to understand regardless of protocol: Odoo's API is model-driven. Almost everything is a CRUD operation against a named model β account.move for invoices, res.partner for contacts, account.account for the chart of accounts. Once you understand this pattern, working with any Odoo module follows the same playbook.
Authentication
Odoo uses a two-step authentication process:
- Call the
authenticatemethod to get a user ID (uid) - Use that uid plus your API key or password on all subsequent calls
import xmlrpc.client
url = "https://yourodoo.com"
db = "your_database"
username = "admin@example.com"
password = "your_api_key_or_password" # Use API keys in production
# Step 1: Get the uid
common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common")
uid = common.authenticate(db, username, password, {})
# Step 2: Create the models proxy
models = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object")
A few things worth noting:
API keys vs passwords. Odoo 14+ supports dedicated API keys (Settings > Technical > API Keys). Always use an API key in production rather than a user password. API keys can be scoped and revoked independently.
The uid is per-session. It's a stable identifier for the user account, not a session token that expires. You can store it and reuse it, but re-authenticate if you switch credentials.
Access rights matter. The uid you authenticate with determines what models and records you can read or write. If you're hitting access errors, check the user's security groups in Odoo β not just the API code.
Core API Methods
Every Odoo model supports the same set of methods. These five cover the vast majority of integration use cases:
search_read
The workhorse method. Searches records matching a domain filter and returns field values in a single call.
# Get all posted (validated) customer invoices from the last 30 days
invoices = models.execute_kw(
db, uid, password,
'account.move', 'search_read',
[[
['move_type', '=', 'out_invoice'],
['state', '=', 'posted'],
['invoice_date', '>=', '2025-02-01']
]],
{
'fields': ['name', 'partner_id', 'amount_total', 'invoice_date', 'payment_state'],
'limit': 100,
'offset': 0,
'order': 'invoice_date desc'
}
)
Always specify fields explicitly. Omitting it returns every field on the model, which is slow and produces a lot of noise.
create
Creates one record and returns its ID.
invoice_id = models.execute_kw(
db, uid, password,
'account.move', 'create',
[{
'move_type': 'out_invoice',
'partner_id': 42,
'invoice_date': '2025-03-01',
'invoice_line_ids': [
[0, 0, {
'name': 'Consulting services - March 2025',
'quantity': 10,
'price_unit': 150.0,
'account_id': 11,
}]
]
}]
)
The [0, 0, {...}] syntax is Odoo's "One2many command" for creating nested records inline. It's unusual but consistent β you'll see it everywhere relational fields appear.
write
Updates one or more records.
models.execute_kw(
db, uid, password,
'account.move', 'write',
[[invoice_id], {'ref': 'PO-2025-001'}]
)
unlink
Deletes records. Use with care β Odoo often prevents deletion of posted records to preserve audit trails.
models.execute_kw(db, uid, password, 'account.move', 'unlink', [[invoice_id]])
execute_kw with custom methods
Some actions (like posting an invoice) aren't CRUD β they're state transitions. You call these as named methods on the model:
# Post (validate) a draft invoice
models.execute_kw(db, uid, password, 'account.move', 'action_post', [[invoice_id]])
Accounting Module: Key Models
Here's a map of the models you'll use most when integrating with Odoo Accounting.
Invoices and Bills β account.move
This single model handles customer invoices, vendor bills, credit notes, and journal entries. The move_type field distinguishes them:
| move_type | Description |
|---|---|
out_invoice | Customer invoice |
in_invoice | Vendor bill |
out_refund | Customer credit note |
in_refund | Vendor credit note |
out_receipt | Sales receipt |
in_receipt | Purchase receipt |
entry | Journal entry |
Key fields to know:
fields = [
'name', # Invoice number (e.g. INV/2025/0042)
'move_type', # See table above
'state', # draft, posted, cancel
'payment_state', # not_paid, in_payment, paid, partial, reversed
'partner_id', # Customer or vendor
'invoice_date',
'invoice_date_due',
'amount_untaxed',
'amount_tax',
'amount_total',
'amount_residual', # Outstanding balance
'currency_id',
'invoice_line_ids',
'journal_id',
'ref', # External reference / PO number
]
Invoice Lines β account.move.line
Individual line items on an invoice. You can query these directly for detailed reporting:
lines = models.execute_kw(
db, uid, password,
'account.move.line', 'search_read',
[[['move_id', '=', invoice_id]]],
{'fields': ['name', 'quantity', 'price_unit', 'price_subtotal', 'tax_ids', 'account_id']}
)
Payments β account.payment
Records of money in/out. When a payment is registered against an invoice, Odoo creates a reconciliation between the payment and the invoice line.
# Get all customer payments
payments = models.execute_kw(
db, uid, password,
'account.payment', 'search_read',
[[
['payment_type', '=', 'inbound'],
['state', '=', 'posted']
]],
{'fields': ['name', 'partner_id', 'amount', 'date', 'journal_id', 'ref']}
)
To register a payment against a specific invoice:
# Create payment
payment_id = models.execute_kw(
db, uid, password,
'account.payment', 'create',
[{
'payment_type': 'inbound',
'partner_type': 'customer',
'partner_id': 42,
'amount': 1500.0,
'date': '2025-03-10',
'journal_id': 1,
'ref': 'INV/2025/0042',
}]
)
# Validate the payment
models.execute_kw(db, uid, password, 'account.payment', 'action_post', [[payment_id]])
Chart of Accounts β account.account
accounts = models.execute_kw(
db, uid, password,
'account.account', 'search_read',
[[['deprecated', '=', False]]],
{'fields': ['code', 'name', 'account_type', 'reconcile']}
)
The account_type field (Odoo 16+) replaced the old user_type_id relation. The full list of values is: asset_receivable, asset_cash, asset_current, asset_non_current, asset_prepayments, asset_fixed, liability_payable, liability_credit_card, liability_current, liability_non_current, equity, equity_unaffected, income, income_other, expense, expense_depreciation, expense_direct_cost, off_balance.
Contacts β res.partner
Both customers and vendors live here. The customer_rank and supplier_rank fields indicate their commercial relationship:
customers = models.execute_kw(
db, uid, password,
'res.partner', 'search_read',
[[['customer_rank', '>', 0]]],
{'fields': ['name', 'email', 'phone', 'vat', 'street', 'city', 'country_id', 'property_payment_term_id']}
)
Working with the JSON-RPC Interface
If you prefer JSON (or you're working in a language without a mature XML-RPC client), the JSON-RPC endpoint works just as well:
async function odooRequest(url, service, method, params) {
const response = await fetch(`${url}/web/dataset/call_kw`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {
model: params.model,
method: params.method,
args: params.args,
kwargs: params.kwargs || {}
}
})
});
const data = await response.json();
return data.result;
}
// Authenticate
const uid = await odooRequest(url, 'common', 'authenticate', {
db,
login: username,
password: apiKey
});
// Fetch invoices
const invoices = await odooRequest(url, 'object', 'execute_kw', {
model: 'account.move',
method: 'search_read',
args: [[[['move_type', '=', 'out_invoice'], ['state', '=', 'posted']]]],
kwargs: {
fields: ['name', 'partner_id', 'amount_total', 'payment_state'],
limit: 50
}
});
Working with the Native REST API (Odoo 17+)
Note: This section covers the built-in REST API shipped with Odoo 17 and 18 β not any of the third-party REST modules available on the Odoo App Store. The native REST API is experimental as of Odoo 18 and has limited official documentation. For production integrations targeting Odoo 16 or earlier, use XML-RPC or JSON-RPC instead.
Authentication
The REST API authenticates using your API key as a bearer token. Generate one in Odoo under Preferences > Account Security > New API Key, then pass it in every request:
Authorization: Bearer <your_api_key>
Content-Type: application/json
If your Odoo instance hosts multiple databases, add the database name as a header:
X-Odoo-Database: your_database_name
Reading Records (GET)
import requests
base_url = "https://yourodoo.com"
api_key = "your_api_key"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
# Get all posted customer invoices
response = requests.get(
f"{base_url}/api/account.move",
headers=headers,
params={
"domain": '[["move_type","=","out_invoice"],["state","=","posted"]]',
"fields": '["name","partner_id","amount_total","payment_state","invoice_date"]',
"limit": 50,
}
)
invoices = response.json()
Creating Records (POST)
response = requests.post(
f"{base_url}/api/account.move",
headers=headers,
json={
"move_type": "out_invoice",
"partner_id": 42,
"invoice_date": "2025-03-01",
"invoice_line_ids": [
[0, 0, {
"name": "Consulting services",
"quantity": 5,
"price_unit": 200.0,
"account_id": 11,
}]
]
}
)
new_invoice = response.json()
invoice_id = new_invoice["id"]
Calling Methods
State transitions like posting an invoice require calling a named method:
response = requests.post(
f"{base_url}/api/account.move/{invoice_id}/action_post",
headers=headers,
)
The JSON-2 API (Odoo 19+)
Odoo 19 introduced a cleaner replacement for XML-RPC and JSON-RPC called the JSON-2 API, located at /json/2/{model}/{method}. It uses the same bearer token auth but with named arguments in the request body instead of positional ones:
response = requests.post(
f"{base_url}/json/2/account.move/search_read",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"X-Odoo-Database": "your_database_name",
},
json={
"domain": [["move_type", "=", "out_invoice"], ["state", "=", "posted"]],
"fields": ["name", "partner_id", "amount_total", "payment_state"],
"limit": 50,
}
)
The JSON-2 API is the long-term direction for Odoo's external API. If you're on Odoo 19 or planning to migrate, it's worth building against this rather than XML-RPC.
Pagination and Performance
Odoo doesn't paginate automatically. You control it with limit and offset:
def fetch_all_invoices(models, db, uid, password, batch_size=200):
offset = 0
all_invoices = []
while True:
batch = models.execute_kw(
db, uid, password,
'account.move', 'search_read',
[[['move_type', '=', 'out_invoice'], ['state', '=', 'posted']]],
{
'fields': ['name', 'partner_id', 'amount_total', 'invoice_date'],
'limit': batch_size,
'offset': offset,
'order': 'id asc'
}
)
if not batch:
break
all_invoices.extend(batch)
offset += batch_size
return all_invoices
A few performance tips:
- Always use
order: 'id asc'when paginating to ensure stable ordering - Keep
limitunder 500 β larger batches increase memory pressure on the server - Use
search(returns IDs only) thenread(fetches specific fields by IDs) instead ofsearch_readwhen you need to pre-filter a large set before loading fields - Avoid fetching binary fields (like
image_1920) unless you actually need them
Handling Odoo Versions
Odoo's API surface changes between versions. The main gotchas:
Version 14 to 16: The account type system changed from a relational user_type_id field to a direct account_type string field. If you support multiple Odoo versions, you'll need to branch on this.
Version 17: A REST API was added alongside the existing XML-RPC and JSON-RPC protocols.
Version 19+: Odoo announced the deprecation of both the XML-RPC and JSON-RPC endpoints, with removal planned for Odoo 20 (fall 2026). Odoo 19 introduced a JSON-2 API as the replacement.
You can check the server version via:
version_info = common.version()
# Returns e.g. {'server_version': '16.0', 'server_version_info': [16, 0, 0, 'final', 0, '']}
Error Handling
Odoo returns errors inside the XML-RPC fault mechanism or the JSON-RPC error field. In Python, XML-RPC errors surface as xmlrpc.client.Fault exceptions:
try:
result = models.execute_kw(db, uid, password, 'account.move', 'create', [data])
except xmlrpc.client.Fault as e:
print(f"Odoo error {e.faultCode}: {e.faultString}")
Common error patterns:
AccessErrorβ the user doesn't have permission on that model or recordValidationErrorβ a required field is missing or a constraint failedUserErrorβ a business rule blocked the operation (e.g. trying to delete a posted invoice)
Always log the full faultString β Odoo includes a Python traceback in it, which makes debugging much faster.
Webhooks and Real-time Sync
Odoo doesn't have native webhooks in its community edition. For real-time sync, your options are:
- Polling β query for records updated since a timestamp using
write_dateorcreate_datefields - Odoo Automation β create a scheduled action or server action that calls an external URL when a record changes (Enterprise or custom module)
- Database-level triggers β only viable for self-hosted deployments where you have database access
- Odoo Studio β Enterprise feature that can configure webhook-style integrations
For most integrations, polling with write_date filtering works well enough:
from datetime import datetime, timedelta
since = (datetime.utcnow() - timedelta(hours=1)).strftime('%Y-%m-%d %H:%M:%S')
updated_invoices = models.execute_kw(
db, uid, password,
'account.move', 'search_read',
[[['write_date', '>=', since], ['move_type', '=', 'out_invoice']]],
{'fields': ['id', 'name', 'state', 'payment_state', 'write_date']}
)
Using a Unified API Instead
If you're building a product that needs to integrate with multiple accounting and ERP systems β QuickBooks, Xero, NetSuite, Sage, Microsoft Business Central β maintaining direct API integrations with each platform is a significant ongoing investment. Each one has different auth flows, data models, pagination patterns, and versioning behavior.
A unified accounting API like Apideck normalizes these differences behind a single endpoint. You write the integration once to a standardized Invoice, Payment, or Ledger Account model, and the platform handles the per-connector translation. This is especially useful if you're targeting vertical SaaS, embedded finance, or any segment where your customers run a mix of accounting platforms.
Disclaimer: Apideck does not currently support Odoo as a connector on the Accounting API yet. If Odoo coverage is a requirement, you'll need to build and maintain the direct integration as described in this guide.
Wrapping Up
Odoo's API is mature, consistent, and surprisingly powerful once you get past the initial learning curve. The model-driven RPC approach means that anything you can do in the UI, you can do via API β including custom fields and modules added by your customers.
The main things to keep in mind as you build:
- Authenticate with API keys, not passwords, in production
- Learn the domain filter syntax β it's the same across every model and covers complex queries
- Always specify
fieldsinsearch_readto avoid pulling unnecessary data - Use
write_datefiltering for incremental sync rather than full refreshes - Handle Odoo version differences explicitly, especially around the accounting type system
If you're integrating with Odoo as part of a broader multi-ERP strategy, it's worth evaluating whether a unified API layer saves you time versus owning each integration directly.
Ready to get started?
Scale your integration strategy and deliver the integrations your customers need in record time.








