DEV Community

Cover image for How to Integrate with the Odoo API
Kate Apideck for Apideck

Posted on • Originally published at apideck.com

How to Integrate with the Odoo API

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:

  1. Call the authenticate method to get a user ID (uid)
  2. 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")
Enter fullscreen mode Exit fullscreen mode

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'
    }
)
Enter fullscreen mode Exit fullscreen mode

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,
            }]
        ]
    }]
)
Enter fullscreen mode Exit fullscreen mode

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'}]
)
Enter fullscreen mode Exit fullscreen mode

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]])
Enter fullscreen mode Exit fullscreen mode

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]])
Enter fullscreen mode Exit fullscreen mode

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
]
Enter fullscreen mode Exit fullscreen mode

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']}
)
Enter fullscreen mode Exit fullscreen mode

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']}
)
Enter fullscreen mode Exit fullscreen mode

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]])
Enter fullscreen mode Exit fullscreen mode

Chart of Accounts — account.account

accounts = models.execute_kw(
    db, uid, password,
    'account.account', 'search_read',
    [[['deprecated', '=', False]]],
    {'fields': ['code', 'name', 'account_type', 'reconcile']}
)
Enter fullscreen mode Exit fullscreen mode

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']}
)
Enter fullscreen mode Exit fullscreen mode

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
    }
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

If your Odoo instance hosts multiple databases, add the database name as a header:

X-Odoo-Database: your_database_name
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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,
    }
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A few performance tips:

  • Always use order: 'id asc' when paginating to ensure stable ordering
  • Keep limit under 500 — larger batches increase memory pressure on the server
  • Use search (returns IDs only) then read (fetches specific fields by IDs) instead of search_read when 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, '']}
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode

Common error patterns:

  • AccessError — the user doesn't have permission on that model or record
  • ValidationError — a required field is missing or a constraint failed
  • UserError — 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:

  1. Polling — query for records updated since a timestamp using write_date or create_date fields
  2. Odoo Automation — create a scheduled action or server action that calls an external URL when a record changes (Enterprise or custom module)
  3. Database-level triggers — only viable for self-hosted deployments where you have database access
  4. 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']}
)
Enter fullscreen mode Exit fullscreen mode

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.

Apideck supports Odoo as a connector on both the Unified Accounting API and the CRM API, with 29+ normalized data models. On the accounting side, the connector covers invoices, bills, payments, bill payments, credit notes, journal entries, ledger accounts, tax rates, customers, suppliers, invoice items, company info, purchase orders, expenses, bank accounts, bank feed accounts, bank feed statements, subsidiaries, departments, locations, tracking categories, and attachments. The CRM side adds companies, contacts, leads, opportunities, activities, notes, and users.

You can include Odoo alongside QuickBooks, Xero, and the rest without maintaining a separate direct integration, and without giving up coverage of the resources that matter for real accounting workflows.

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 fields in search_read to avoid pulling unnecessary data
  • Use write_date filtering 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.

Top comments (0)