Odoo is the dominant open-source ERP across Saudi Arabia and the wider MENA region. Thousands of companies use it for accounting, inventory, sales, and HR — and nearly all of them eventually need to connect Odoo to something else: a custom portal, a mobile app, a reporting dashboard, or an external warehouse system.
The problem is that most Odoo integration documentation assumes Python. TypeScript developers are left piecing things together from five-year-old forum posts and incomplete examples.
This guide fixes that. By the end you will have a production-ready OdooClient class, full type safety for Odoo models, and working examples for every common operation — search, read, create, update, delete. The same patterns work for SaaS Odoo (odoo.com) and self-hosted instances.
What You Will Build
- A reusable, type-safe
OdooClientthat wraps XML-RPC calls - Authentication with API keys (more secure than passwords)
- Full CRUD operations against any Odoo model
- REST API usage for Odoo 17+
- A real inventory sync example pulling stock levels to an external system
- Retry logic and error handling for production use
Prerequisites
Before starting, make sure you have:
- Node.js 20+ installed (
node --versionshould show v20 or higher) - TypeScript 5+ and
tsxfor running TypeScript without a build step - Access to an Odoo 17 instance — self-hosted or odoo.com SaaS
- A user account in Odoo with permission to access the models you need
No Python knowledge required.
Step 1: Understanding Odoo's External API
Odoo exposes two ways to integrate from external code.
XML-RPC API is the classic path, stable since Odoo 6. It uses two endpoints:
/xmlrpc/2/common— authentication only/xmlrpc/2/object— all record operations (search, create, write, unlink)
REST API (introduced in Odoo 16, significantly improved in Odoo 17) uses standard JSON over HTTPS at /api/. It is model-scoped: /api/sale.order fetches sales orders, /api/res.partner fetches contacts.
This tutorial covers XML-RPC first because it works across all Odoo versions, then shows the REST equivalent for Odoo 17+.
Step 2: Project Setup
Create a new directory and install dependencies:
mkdir odoo-ts-client && cd odoo-ts-client
npm init -y
npm install xmlrpc dotenv
npm install -D typescript tsx @types/node @types/xmlrpc
npx tsc --initUpdate tsconfig.json to use modern module resolution:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"outDir": "dist",
"esModuleInterop": true
}
}Create a .env file for credentials (never commit this to version control):
ODOO_URL=https://your-instance.odoo.com
ODOO_DB=your-database-name
ODOO_USER=admin@yourcompany.com
ODOO_API_KEY=your-api-key-hereStep 3: Generating an API Key in Odoo
Odoo 14+ supports API keys — revocable, scope-independent credentials that are safer than using a password in code.
To generate one:
- Log in to Odoo as an administrator
- Go to Settings → Technical → API Keys (if you do not see this, activate Developer Mode first: Settings → Developer Tools → Activate the developer mode)
- Click New, give the key a name like
typescript-integration - Copy the generated key immediately — Odoo only shows it once
Paste it into your .env file as ODOO_API_KEY.
Step 4: Building the OdooClient Class
Create src/odoo-client.ts:
import xmlrpc from 'xmlrpc';
export interface OdooConfig {
url: string;
db: string;
username: string;
apiKey: string;
}
export class OdooClient {
private config: OdooConfig;
private uid: number | null = null;
private common: xmlrpc.Client;
private object: xmlrpc.Client;
constructor(config: OdooConfig) {
this.config = config;
const base = new URL(config.url);
const isHttps = base.protocol === 'https:';
const port = base.port ? parseInt(base.port) : (isHttps ? 443 : 80);
const opts = { host: base.hostname, port };
const createClient = isHttps
? xmlrpc.createSecureClient.bind(xmlrpc)
: xmlrpc.createClient.bind(xmlrpc);
this.common = createClient({ ...opts, path: '/xmlrpc/2/common' });
this.object = createClient({ ...opts, path: '/xmlrpc/2/object' });
}
async authenticate(): Promise<number> {
return new Promise((resolve, reject) => {
this.common.methodCall(
'authenticate',
[this.config.db, this.config.username, this.config.apiKey, {}],
(err, uid) => {
if (err) return reject(new Error(`Authentication failed: ${err.message}`));
if (!uid) return reject(new Error('Invalid credentials — check username and API key'));
this.uid = uid as number;
resolve(uid as number);
}
);
});
}
async call<T>(
model: string,
method: string,
args: unknown[],
kwargs: Record<string, unknown> = {}
): Promise<T> {
if (!this.uid) await this.authenticate();
return new Promise((resolve, reject) => {
this.object.methodCall(
'execute_kw',
[this.config.db, this.uid, this.config.apiKey, model, method, args, kwargs],
(err, result) => {
if (err) return reject(new Error(`RPC call ${model}.${method} failed: ${err.message}`));
resolve(result as T);
}
);
});
}
}The call method is the workhorse. It takes:
model— the Odoo model name, e.g.res.partner,sale.order,account.movemethod— the method to call:search_read,create,write,unlinkargs— positional arguments (always an array)kwargs— keyword arguments (fields, limit, order, etc.)
Step 5: Searching and Reading Records
Create src/main.ts:
import 'dotenv/config';
import { OdooClient, OdooConfig } from './odoo-client.js';
const config: OdooConfig = {
url: process.env.ODOO_URL!,
db: process.env.ODOO_DB!,
username: process.env.ODOO_USER!,
apiKey: process.env.ODOO_API_KEY!,
};
const client = new OdooClient(config);
// TypeScript interface for the Odoo partner model
interface OdooPartner {
id: number;
name: string;
email: string | false;
phone: string | false;
country_id: [number, string] | false;
is_company: boolean;
}
async function listSaudiCompanies(): Promise<void> {
// Saudi Arabia country ID in Odoo is 186
const partners = await client.call<OdooPartner[]>(
'res.partner',
'search_read',
[[['is_company', '=', true], ['country_id', '=', 186]]],
{
fields: ['id', 'name', 'email', 'phone', 'country_id'],
limit: 20,
order: 'name asc',
}
);
console.log(`Found ${partners.length} Saudi companies:`);
partners.forEach(p => {
const country = p.country_id ? p.country_id[1] : 'Unknown';
console.log(` [${p.id}] ${p.name} (${country}) — ${p.email || 'no email'}`);
});
}
listSaudiCompanies().catch(console.error);Run it:
npx tsx src/main.tsDomain Filter Syntax
Odoo filters use arrays of triplets: ['field', 'operator', value].
// Contacts from Saudi Arabia
[['country_id', '=', 186]]
// Companies with a phone number
[['is_company', '=', true], ['phone', '!=', false]]
// Records created after 2026-01-01
[['create_date', '>=', '2026-01-01 00:00:00']]
// Partners whose name contains "شركة"
[['name', 'ilike', 'شركة']]Multiple conditions in the same array are combined with AND. Use '|' for OR:
// Records where name starts with "Al" OR "Al-"
['|', ['name', '=like', 'Al%'], ['name', '=like', 'Al-%']]Step 6: Creating Records
interface NewPartnerData {
name: string;
is_company: boolean;
country_id?: number;
email?: string;
phone?: string;
}
async function createPartner(data: NewPartnerData): Promise<number> {
const newId = await client.call<number>(
'res.partner',
'create',
[[data]] // create takes a list containing one dict
);
console.log(`Created partner with ID ${newId}`);
return newId;
}
// Example
const id = await createPartner({
name: 'شركة الأمانة للتقنية',
is_company: true,
country_id: 186, // Saudi Arabia
email: 'info@amanah-tech.sa',
phone: '+966500000000',
});Creating Records with Related Lines (Invoices)
Odoo uses a special command syntax for One2many and Many2many fields. The format is [command, id, values]:
[0, 0, values]— create a new related record[1, id, values]— update an existing related record[2, id, 0]— delete a related record[4, id, 0]— link an existing record without modifying it
interface InvoiceLine {
product_id: number;
quantity: number;
price_unit: number;
name: string;
}
async function createInvoice(
partnerId: number,
lines: InvoiceLine[]
): Promise<number> {
const invoiceId = await client.call<number>(
'account.move',
'create',
[[{
partner_id: partnerId,
move_type: 'out_invoice', // customer invoice
invoice_date: new Date().toISOString().split('T')[0],
invoice_line_ids: lines.map(line => [0, 0, line]),
}]]
);
console.log(`Created invoice #${invoiceId}`);
return invoiceId;
}
await createInvoice(id, [
{ product_id: 1, quantity: 3, price_unit: 500, name: 'AI Consulting Services' },
{ product_id: 2, quantity: 1, price_unit: 1200, name: 'System Integration Setup' },
]);Step 7: Updating Records
The write method takes a list of record IDs and a dict of fields to update:
// Update a single partner
async function updatePartnerEmail(partnerId: number, email: string): Promise<void> {
await client.call<boolean>(
'res.partner',
'write',
[[partnerId], { email }] // first arg is list of IDs
);
}
// Update multiple records at once
async function markPartnersAsCustomer(ids: number[]): Promise<void> {
await client.call<boolean>(
'res.partner',
'write',
[ids, { customer_rank: 1 }]
);
}Step 8: Archiving and Deleting Records
Most Odoo records support soft deletion via the active field:
// Archive (soft-delete) — preferred approach
async function archiveRecord(model: string, id: number): Promise<void> {
await client.call<boolean>(model, 'write', [[id], { active: false }]);
}
// Hard delete — use with caution, may fail on records with dependencies
async function deleteRecord(model: string, id: number): Promise<void> {
await client.call<boolean>(model, 'unlink', [[id]]);
}Prefer archiving over hard deletion. Setting active: false hides a record from all default views while preserving audit history. Hard deletion (unlink) raises a validation error if the record has related records — for example, you cannot delete a partner who has open invoices.
Step 9: Using the REST API (Odoo 17+)
Odoo 17 delivers a much more developer-friendly REST API. It accepts and returns JSON, uses standard HTTP verbs, and authenticates via Bearer token:
async function odooRestFetch<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const url = `${process.env.ODOO_URL}${path}`;
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.ODOO_API_KEY}`,
'X-Odoo-Database': process.env.ODOO_DB!,
...options.headers,
},
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Odoo REST ${response.status} on ${path}: ${body}`);
}
return response.json() as Promise<T>;
}
// List the 10 most recent sales orders
const ordersResult = await odooRestFetch<{
count: number;
records: Array<{
id: number;
name: string;
partner_id: [number, string];
amount_total: number;
}>;
}>(
'/api/sale.order?fields=name,partner_id,amount_total&limit=10&order=date_order desc'
);
console.log(`Total orders: ${ordersResult.count}`);
ordersResult.records.forEach(o => {
console.log(` ${o.name} — ${o.amount_total} SAR`);
});
// Create a contact via REST
const newContact = await odooRestFetch<{ id: number }>(
'/api/res.partner',
{
method: 'POST',
body: JSON.stringify({
name: 'مؤسسة الفهد للاستشارات',
is_company: true,
country_id: 186,
}),
}
);
console.log(`Created contact: ${newContact.id}`);When to use REST vs XML-RPC:
- REST — Odoo 16 or 17, you want cleaner code, standard HTTP semantics
- XML-RPC — Odoo 15 or older, or when calling business methods that are not exposed via REST
Step 10: Production Patterns
Retry on Transient Errors
async function withRetry<T>(
fn: () => Promise<T>,
maxAttempts = 3,
baseDelayMs = 500
): Promise<T> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt === maxAttempts) throw err;
const message = err instanceof Error ? err.message : String(err);
const isTransient =
message.includes('could not serialize') ||
message.includes('ECONNRESET') ||
message.includes('ETIMEDOUT') ||
message.includes('ENOTFOUND');
if (!isTransient) throw err;
const delay = baseDelayMs * Math.pow(2, attempt - 1);
console.warn(`Attempt ${attempt} failed, retrying in ${delay}ms...`);
await new Promise(r => setTimeout(r, delay));
}
}
throw new Error('Unreachable');
}Batch Processing Large Datasets
Odoo performs best when you batch reads into chunks rather than fetching all records at once:
async function* readAllRecords<T>(
model: string,
domain: unknown[][],
fields: string[],
chunkSize = 500
): AsyncGenerator<T[]> {
let offset = 0;
while (true) {
const chunk = await withRetry(() =>
client.call<T[]>(model, 'search_read', [domain], {
fields,
limit: chunkSize,
offset,
order: 'id asc',
})
);
if (chunk.length === 0) break;
yield chunk;
offset += chunk.length;
if (chunk.length < chunkSize) break;
}
}
// Usage
for await (const batch of readAllRecords<OdooPartner>(
'res.partner',
[['is_company', '=', true]],
['id', 'name', 'email']
)) {
console.log(`Processing batch of ${batch.length} partners...`);
await processBatch(batch);
}Step 11: Real-World Example — Inventory Sync
This pattern pulls current stock levels from Odoo and pushes them to an external catalog:
interface OdooProduct {
id: number;
name: string;
default_code: string | false; // internal SKU/reference
qty_available: number;
virtual_available: number; // forecasted quantity after pending moves
list_price: number;
active: boolean;
}
async function syncInventoryToExternalCatalog(): Promise<void> {
console.log('Starting inventory sync...');
let totalSynced = 0;
for await (const batch of readAllRecords<OdooProduct>(
'product.product',
[['active', '=', true], ['type', '=', 'product']],
['id', 'name', 'default_code', 'qty_available', 'virtual_available', 'list_price']
)) {
const toSync = batch.filter(p => p.default_code); // skip products without SKU
await Promise.all(
toSync.map(product =>
updateExternalCatalog({
sku: product.default_code as string,
name: product.name,
inStock: product.qty_available,
forecasted: product.virtual_available,
priceRiyal: product.list_price,
})
)
);
totalSynced += toSync.length;
console.log(`Synced ${totalSynced} products so far...`);
}
console.log(`Inventory sync complete. ${totalSynced} products updated.`);
}
// Stub — replace with your actual external system call
async function updateExternalCatalog(data: {
sku: string;
name: string;
inStock: number;
forecasted: number;
priceRiyal: number;
}): Promise<void> {
// e.g. await fetch('https://your-portal.com/api/inventory', { method: 'PUT', body: JSON.stringify(data) })
console.log(` Updated SKU ${data.sku}: ${data.inStock} in stock`);
}
syncInventoryToExternalCatalog().catch(console.error);Troubleshooting
"Access Denied" on a specific model
The integration user does not have access rights for that model. In Odoo go to Settings → Users, open the integration user, and check their access rights. Consider creating a dedicated low-privilege user for the integration with only the models it needs.
RPC error "Expected singleton"
You passed a list where Odoo expected a single record. This usually means your domain returned more than one record when calling a method that expects exactly one. Add limit: 1 to your search_read call or use search followed by a targeted read.
Dates are off by 3 hours
Odoo stores all datetimes in UTC internally. Saudi Arabia is UTC+3 (AST). When filtering by date ranges, always pass UTC times, or you will miss records near midnight boundaries.
Rate limiting on odoo.com SaaS
Odoo's SaaS enforces per-database request quotas. For bulk operations, add a small delay between batches and avoid parallel bursts.
Next Steps
- Explore the n8n automation tutorial to trigger Odoo API calls from visual workflows without writing code every time
- Read The ERP Trap: Why a New ERP Won't Fix Your Data to understand when integration is the right answer — and when it is not
- Connect Odoo to WhatsApp Business with the WhatsApp Cloud API guide to send Arabic invoice notifications directly to your customers
Conclusion
You now have a production-ready foundation for integrating any external TypeScript application with Odoo 17. The OdooClient class handles authentication, retry logic, and type safety — you can extend it to any of Odoo's hundreds of models using the same patterns shown here.
The biggest integration wins in the MENA market come not from replacing Odoo but from connecting it: a customer-facing portal that reads live stock from Odoo, a BI dashboard that pulls invoices nightly, a WhatsApp bot that confirms delivery dates. Those integrations are what turn an ERP investment into a competitive advantage.
Need help building a custom Odoo integration? Our team has delivered API integration projects across Saudi Arabia, the Gulf, and North Africa — from simple inventory syncs to full multi-system automation pipelines. Get in touch for a no-commitment technical assessment.