Install
openclaw skills install @akdira/odoo-connectorAI Agent skill for Odoo 17/18/19 XML-RPC API integration — authentication, CRUD, search operations
openclaw skills install @akdira/odoo-connectorComplete guide for AI agents to interact with Odoo instances via XML-RPC protocol. Works with Odoo 17, 18, and 19.
This skill enables AI agents to programmatically interact with Odoo ERP systems through its built-in XML-RPC API. Odoo exposes two XML-RPC endpoints that provide full access to all models (tables) in the database: search, read, create, update, delete, and execute any model method.
Key capabilities:
xmlrpc.client stdlib onlyOdoo provides multiple API access methods:
XML-RPC is the most reliable for AI agent automation because it's stateless (no session cookies), supports all operations, and works with any programming language that has XML-RPC client support.
🔴 CRITICAL: You CANNOT use XML-RPC without valid API credentials. Before writing ANY code, make sure you have an API key. If you don't, follow the steps below FIRST.
Do you have Odoo API credentials (API key)?
├── YES → Skip to "Connection Setup" section below
└── NO → Follow Step 1 & Step 2 below, THEN continue
{ODOO_BASE_URL}/web/loginadmin / admin — change immediately after first login⚠️ Encountering CAPTCHA or Cloudflare challenges? Many production Odoo instances use Cloudflare/WAF protection that may present CAPTCHA challenges during login. Important: Only proceed if you have explicit authorization to access this Odoo instance.
If you encounter CAPTCHA challenges during legitimate access:
⚠️ Legal & ToS Notice: Bypassing security measures without authorization may violate the service's Terms of Service and applicable laws. Only use automation tools on systems you own or have explicit written permission to access. Repeated failed login attempts may result in account lockout.
Why API keys instead of passwords? API keys are more secure — they can be revoked independently, don't expose your login password, and are the recommended method for programmatic access.
"AI Agent Automation - {date}").env file)Can't find the API Keys section? Make sure you're logged in as an administrator. In some Odoo versions, you may need to enable Developer Mode first: go to Settings → scroll to bottom → click "Activate the developer mode".
Before proceeding, verify your credentials authenticate successfully:
import xmlrpc.client
common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common")
uid = common.authenticate(database, username, api_key, {})
if not uid:
raise Exception("Auth failed — check database name, username, and API key")
print(f"Authenticated as UID: {uid}")
| Item | Where to get it | Example |
|---|---|---|
| Odoo URL | Your server admin / deployment config | https://erp.example.com |
| Database name | Ask admin or list via common.db.list() | production_db |
| Username | Your Odoo login email | admin@example.com |
| API Key | Settings → Users → Your User → API Keys → New | abc123def456... |
📖 For detailed troubleshooting and best practices: See docs/authentication.md
Python 3.8+ with standard library only:
import xmlrpc.client # Built-in, no pip install needed
Odoo server requirements:
This skill includes comprehensive documentation and examples:
akdira/odoo-connector/
├── SKILL.md ← You are here
├── README.md ← Overview, installation, features
├── _meta.json ← Skill metadata
├── LICENSE ← MIT-0 license
├── .gitignore
├── docs/
│ ├── authentication.md ← Login + API key setup guide
│ ├── installation.md ← Installation & prerequisites
│ ├── quickstart.md ← Quick start tutorial
│ ├── api-reference.md ← Full API reference (all models)
│ └── troubleshooting.md ← Common errors & solutions
├── examples/
│ ├── README.md ← Examples overview
│ ├── sales-order.md ← Sales order creation example
│ └── inventory-sync.md ← Inventory sync scenario
├── scripts/
│ ├── test-connection.py ← Test Odoo connection
│ └── bulk-import.py ← Bulk data import script
├── CHANGELOG.md
├── CONTRIBUTING.md
└── SECURITY.md
GitHub Repository: https://github.com/akdira/odoo-connector ClawHub Skill Page: https://clawhub.ai/akdira/odoo-connector
Key files to read:
Every Odoo instance exposes two XML-RPC endpoints:
{BASE_URL}/xmlrpc/2/common # Authentication + version info
{BASE_URL}/xmlrpc/2/object # All data operations
import xmlrpc.client
# Configuration
url = "https://your-odoo-instance.com"
database = "your_database_name"
username = "your_login"
password = "your_password"
# Create proxies
common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common")
models = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object")
Why ServerProxy instead of raw calls: ServerProxy handles XML-RPC envelope wrapping, type conversion, and error handling automatically.
Authentication is a two-step process that returns a numeric user ID (UID).
version = common.version()
# Returns dict with server version info:
# {
# 'server_version': '17.0+e',
# 'server_version_info': [17, 0, 0, 'final', 0, ''],
# 'server_serie': '17.0',
# 'protocol_version': 1
# }
This confirms the server is reachable and responsive. Always do this first in your script.
uid = common.authenticate(database, username, password, {})
database: exact database name (case-sensitive)username: login email or usernamepassword: user's password{}: environment dict (can be empty)Returns: Numeric user ID (e.g., 2 for admin), or raises AuthenticationError if credentials are wrong.
Store the UID — you need it for every subsequent operation.
try:
uid = common.authenticate(database, username, password, {})
except Exception as e:
print(f"Authentication failed: {e}")
# Common causes:
# - Wrong database name
# - Invalid username/password
# - User doesn't exist
# - Database not accessible
All data operations go through the models proxy using execute_kw(). This method has a consistent signature:
models.execute_kw(
database, # string: database name
uid, # int: authenticated user ID
password, # string: password
model_name, # string: Odoo model (e.g., 'res.partner')
method_name, # string: method to call
args, # list: positional arguments
kwargs # dict: keyword arguments (optional)
)
Find records matching criteria. Returns list of matching IDs.
# Search all partners
partner_ids = models.execute_kw(
database, uid, password,
'res.partner', 'search',
[[]] # Empty domain = match all
)
# Returns: [1, 2, 3, 5, 8, ...]
# Search with domain filter
customer_ids = models.execute_kw(
database, uid, password,
'res.partner', 'search',
[[('customer_rank', '>', 0)]] # Only customers
)
# Search with limits
recent_ids = models.execute_kw(
database, uid, password,
'res.partner', 'search',
[[('create_date', '>', '2024-01-01')]],
{'limit': 100, 'offset': 0, 'order': 'create_date DESC'}
)
Domain syntax: List of tuples [(field, operator, value)]
Common operators:
= equals!= not equals> < >= <= numeric/date comparisonin value in list: [('state', 'in', ['draft', 'sent'])]not in not in listilike case-insensitive contains: [('name', 'ilike', 'john')]like case-sensitive contains=like pattern match with % and _ (SQL LIKE)& AND between criteria (default)| OR between next two criteria! NOT the next criterionMultiple criteria are ANDed by default:
# Partners named John who are customers
domain = [
('name', 'ilike', 'john'),
('customer_rank', '>', 0)
]
OR operator requires prefix:
# Partners named John OR Mary
domain = [
'|',
('name', 'ilike', 'john'),
('name', 'ilike', 'mary')
]
Fetch field values for specific record IDs.
# Read specific fields for partner ID 1
partner = models.execute_kw(
database, uid, password,
'res.partner', 'read',
[[1]], # List of IDs
{'fields': ['name', 'email', 'phone', 'customer_rank']}
)
# Returns: [{'id': 1, 'name': 'John Doe', 'email': 'john@example.com', ...}]
# Read multiple records
partners = models.execute_kw(
database, uid, password,
'res.partner', 'read',
[[1, 2, 3]],
{'fields': ['name']}
)
Important: Fields use internal names. Common Odoo models:
| Model | Typical Fields |
|---|---|
res.partner | name, email, phone, customer_rank, supplier_rank, company_id |
res.users | login, name, email, active, groups_id |
res.company | name, website, logo, currency_id |
product.product | name, default_code, list_price, type |
sale.order | name, partner_id, state, amount_total, date_order |
account.move | name, partner_id, state, amount_total, invoice_date |
Combine search and read for efficiency:
customers = models.execute_kw(
database, uid, password,
'res.partner', 'search_read',
[[('customer_rank', '>', 0)]], # Domain filter
{
'fields': ['name', 'email', 'phone'],
'limit': 50,
'offset': 0,
'order': 'name ASC'
}
)
# Returns: [{'id': 5, 'name': 'Alice', ...}, {'id': 8, 'name': 'Bob', ...}, ...]
This is more efficient than separate search + read calls (one round-trip instead of two).
⚠️ WARNING: Write Operations Affect Live Systems Create/update/delete operations immediately modify the connected Odoo database. If connected to a production instance, these changes are real and may trigger downstream business workflows (notifications, invoices, inventory updates, etc.).
- Always test in a staging/development environment first
- Verify you have proper authorization before writing to production
- Consider using
search_readfirst to check if records already exist
Create new records:
# Create a new partner
new_partner_id = models.execute_kw(
database, uid, password,
'res.partner', 'create',
[{
'name': 'Jane Smith',
'email': 'jane@example.com',
'phone': '+1-555-0123',
'customer_rank': 1
}]
)
# Returns: 42 (new record ID)
Modify existing records:
# Update partner fields
result = models.execute_kw(
database, uid, password,
'res.partner', 'write',
[[1], {'phone': '+1-555-9999', 'city': 'New York'}]
# ^ ID list ^ fields to update
)
# Returns: True on success
Note: First argument to write is a list of IDs (allows batch updates).
Remove records:
result = models.execute_kw(
database, uid, password,
'res.partner', 'unlink',
[[42]] # List of IDs to delete
)
# Returns: True on success
Warning: Some models have constraints preventing deletion (e.g., records referenced by other transactions). Use with caution.
Get count without fetching data:
count = models.execute_kw(
database, uid, password,
'res.partner', 'search_count',
[[('customer_rank', '>', 0)]]
)
# Returns: 150
fields = models.execute_kw(
database, uid, password,
'res.partner', 'fields_get',
[[]], # Empty list = all fields
{'attributes': ['string', 'type', 'help', 'required']}
)
# Returns dict with field metadata
Useful for:
Common Odoo field types:
char / text: String fieldsselection: Dropdown with predefined valuesboolean: True/Falseinteger / float: Numeric fieldsdate / datetime: Date/time fieldsmany2one: Foreign key to single record (returns dict {'id': 1, 'name': 'Partner Name'})one2many / many2many: Reverse relations (returns list of IDs)Selection field values:
# Get available values for a selection field
fields = models.execute_kw(
database, uid, password,
'sale.order', 'fields_get',
[[]],
{'attributes': ['selection']}
)
state_values = fields['state']['selection']
# Returns: [('draft', 'Quotation'), ('sent', 'Quotation Sent'), ('sale', 'Sales Order'), ...]
users = models.execute_kw(
database, uid, password,
'res.users', 'search_read',
[[('active', '=', True)]],
{'fields': ['login', 'name', 'email', 'create_date']}
)
for user in users:
print(f"{user['name']} ({user['login']}) - {user['email']}")
company = models.execute_kw(
database, uid, password,
'res.company', 'read',
[[1]], # Company ID 1 is typically the main company
{'fields': ['name', 'website', 'phone', 'email', 'city', 'country_id']}
)
print(f"Company: {company[0]['name']}")
print(f"Website: {company[0]['website']}")
orders = models.execute_kw(
database, uid, password,
'sale.order', 'search_read',
[[('state', 'in', ['sale', 'done'])]],
{
'fields': ['name', 'partner_id', 'amount_total', 'date_order'],
'order': 'date_order DESC',
'limit': 10
}
)
for order in orders:
partner_name = order['partner_id'][1] # Many2one returns [id, name]
print(f"{order['name']}: {partner_name} - ${order['amount_total']}")
⚠️ This creates a real record in the connected Odoo instance. Ensure you're connected to the correct environment (staging vs production).
new_customer = models.execute_kw(
database, uid, password,
'res.partner', 'create',
[{
'name': 'Acme Corporation',
'email': 'contact@acme.com',
'phone': '+62-21-1234567',
'website': 'https://acme.com',
'street': 'Jl. Sudirman No. 1',
'city': 'Jakarta',
'zip': '10220',
'country_id': 102, # Indonesia
'customer_rank': 1
}]
)
⚠️ CRITICAL: State-Changing Actions Trigger Downstream Workflows Methods like
action_confirm,action_cancel, oraction_postare real business operations that may trigger:
- Order fulfillment and shipping workflows
- Invoice generation and payment processing
- Inventory reservations and stock moves
- Email notifications to customers
- Accounting journal entries
Always verify the order state and get explicit approval before executing state-changing actions on production systems.
Some models have custom methods you can call:
# Confirm a quotation (change state to 'sale')
# WARNING: This is a real business action - it may trigger fulfillment, invoicing, and notifications
models.execute_kw(
database, uid, password,
'sale.order', 'action_confirm',
[[42]] # Order ID 42
)
# Generate invoice from sales order
# WARNING: This creates actual invoices that affect accounting
models.execute_kw(
database, uid, password,
'sale.order', 'action_view_invoice',
[[42]]
)
Tip: Check model source code or documentation for available methods.
# Update multiple partners at once
partner_ids = [5, 8, 12, 15]
models.execute_kw(
database, uid, password,
'res.partner', 'write',
[partner_ids, {'customer_rank': 0}] # Set all to non-customers
)
| Error | Cause | Solution |
|---|---|---|
KeyError: 'ir.http' | Database not initialized | Initialize with odoo -i base -d dbname |
ValueError: Invalid field 'xyz' | Wrong field name | Check field name with fields_get() |
AccessError | User lacks permissions | Use admin account or grant access rights |
AuthenticationError | Wrong credentials | Verify database name, username, password |
500 Internal Server Error | Module error or DB issue | Check Odoo server logs |
ValidationError | Data constraint violated | Check required fields, unique constraints |
import xmlrpc.client
try:
# Verify connection
version = common.version()
print(f"Connected to Odoo {version['server_version']}")
# Authenticate
uid = common.authenticate(database, username, password, {})
if not uid:
raise Exception("Authentication returned no UID")
# Perform operations safely
records = models.execute_kw(
database, uid, password,
'res.partner', 'search_read',
[[('active', '=', True)]],
{'fields': ['name'], 'limit': 5}
)
if not records:
print("No records found")
else:
print(f"Found {len(records)} records")
except xmlrpc.client.Fault as e:
print(f"Odoo error: {e.faultString}")
except Exception as e:
print(f"Connection error: {e}")
search_read instead of separate search + read — one call instead of twolimit and offsetfields=[] (returns all fields) unless you actually need everythingsearch_count to check existence — faster than search if you only need to know if records existSome fields changed between versions:
| Version | Logo field | Notes |
|---|---|---|
| Odoo 17 | logo_1920 | Standard logo field |
| Odoo 18/19 | logo | Simplified naming |
| Odoo 18/19 | logo_web | Web-specific variant |
Always verify field names with fields_get() if you encounter "Invalid field" errors.
fields_get liberally — discover exact field names and typesMany2one fields return a tuple (id, display_name):
order = models.execute_kw(
database, uid, password,
'sale.order', 'read',
[[1]],
{'fields': ['partner_id', 'user_id']}
)
partner_name = order[0]['partner_id'][1] # "John Doe"
partner_id = order[0]['partner_id'][0] # 5
One2many / Many2many return list of IDs:
products = models.execute_kw(
database, uid, password,
'sale.order', 'read',
[[1]],
{'fields': ['order_line']}
)
line_ids = products[0]['order_line'] # [10, 11, 12]
#!/usr/bin/env python3
"""
Odoo XML-RPC API Client Template
Usage: python3 odoo_client.py
"""
import xmlrpc.client
import sys
# Configuration
ODOO_URL = "https://your-odoo-instance.com"
ODOO_DB = "your_database"
ODOO_USERNAME = "your_login"
ODOO_PASSWORD = "your_password"
def connect():
"""Establish connection and authenticate"""
try:
common = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/common")
models = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/object")
# Verify connection
version = common.version()
print(f"✓ Connected to Odoo {version['server_version']}")
# Authenticate
uid = common.authenticate(ODOO_DB, ODOO_USERNAME, ODOO_PASSWORD, {})
if not uid:
raise Exception("Authentication failed: no UID returned")
print(f"✓ Authenticated as UID {uid}")
return common, models, uid
except Exception as e:
print(f"✗ Connection failed: {e}")
sys.exit(1)
def main():
"""Main execution"""
common, models, uid = connect()
# Your operations here
# Example: List first 5 partners
partners = models.execute_kw(
ODOO_DB, uid, ODOO_PASSWORD,
'res.partner', 'search_read',
[[('active', '=', True)]],
{'fields': ['name', 'email'], 'limit': 5}
)
print(f"\nFound {len(partners)} partners:")
for p in partners:
print(f" • {p['name']} ({p['email']})")
if __name__ == "__main__":
main()
Some Odoo configurations require the exact database name. Double-check:
# List available databases
databases = common.db.list()
print(databases)
Your user may lack model access. Check:
active=True in res.users)For large datasets:
limit parameter (e.g., limit=1000)offsetcommon = xmlrpc.client.ServerProxy(
f"{ODOO_URL}/xmlrpc/2/common",
allow_none=True,
verbose=False
)
/root/.openclaw/workspace/memory/odoo-xmlrpc-api-setup.md