écrits/tutorial/2026/08
Tutorial7 août 2026·30 min

Intégration API externe Odoo 17 : Guide complet TypeScript

Apprenez à intégrer Odoo 17 via son API externe en TypeScript : authentification XML-RPC, clés API, opérations CRUD typées, REST API Odoo 17+ et un exemple concret de synchronisation de stock — sans Python.

Odoo est le système ERP open source le plus déployé en Arabie Saoudite et dans la région MENA. Des milliers d'entreprises l'utilisent pour la comptabilité, la gestion de stock, les ventes et les RH — et la grande majorité d'entre elles ont besoin, à un moment ou un autre, de connecter Odoo à autre chose : un portail personnalisé, une application mobile, un tableau de bord de reporting ou un système d'entrepôt externe.

Le problème : la documentation officielle sur l'intégration Odoo est quasiment intégralement écrite pour Python. Les développeurs TypeScript se retrouvent à assembler des fils de forum vieux de cinq ans et des exemples incomplets.

Ce guide corrige cela. Vous en sortirez avec une classe OdooClient prête pour la production, une sécurité des types complète pour les modèles Odoo, et des exemples fonctionnels pour chaque opération courante — recherche, lecture, création, modification, suppression. Ces mêmes patterns fonctionnent sur Odoo SaaS (odoo.com) et les instances auto-hébergées.

Ce que vous allez construire

  • Une classe OdooClient réutilisable et typée qui encapsule les appels XML-RPC
  • Une authentification par clés API (plus sécurisée que les mots de passe)
  • Des opérations CRUD complètes sur n'importe quel modèle Odoo
  • L'utilisation de l'API REST pour Odoo 17+
  • Un exemple concret de synchronisation de niveaux de stock vers un système externe
  • Une logique de retry et de gestion des erreurs pour la production

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20+ installé (node --version doit afficher v20 ou supérieur)
  • TypeScript 5+ et tsx pour exécuter du TypeScript sans étape de build
  • Un accès à une instance Odoo 17 — auto-hébergée ou via odoo.com
  • Un compte utilisateur Odoo avec les droits d'accès aux modèles dont vous avez besoin

Aucune connaissance de Python n'est requise.


Étape 1 : Comprendre l'API externe Odoo

Odoo expose deux chemins pour s'intégrer depuis du code externe.

L'API XML-RPC est le chemin classique, stable depuis Odoo 6. Elle utilise deux points d'accès :

  • /xmlrpc/2/common — authentification uniquement
  • /xmlrpc/2/object — toutes les opérations sur les enregistrements (recherche, création, modification, suppression)

L'API REST (introduite dans Odoo 16, considérablement améliorée dans Odoo 17) utilise du JSON standard sur HTTPS via /api/. Elle est orientée modèle : /api/sale.order récupère les bons de commande, /api/res.partner récupère les contacts.

Ce tutoriel couvre XML-RPC en premier car elle fonctionne sur toutes les versions Odoo, puis montre l'équivalent REST pour Odoo 17+.


Étape 2 : Mise en place du projet

Créez un nouveau répertoire et installez les dépendances :

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 --init

Mettez à jour tsconfig.json pour utiliser la résolution de modules moderne :

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "outDir": "dist",
    "esModuleInterop": true
  }
}

Créez un fichier .env pour les identifiants (ne le commitez jamais dans votre dépôt) :

ODOO_URL=https://votre-instance.odoo.com
ODOO_DB=nom-de-votre-base-de-donnees
ODOO_USER=admin@votreentreprise.com
ODOO_API_KEY=votre-cle-api

Étape 3 : Générer une clé API dans Odoo

Odoo 14+ supporte les clés API — des identifiants révocables et indépendants du mot de passe, plus sûrs à utiliser dans le code.

Pour en générer une :

  1. Connectez-vous à Odoo en tant qu'administrateur
  2. Allez dans Paramètres → Technique → Clés API (si vous ne voyez pas cette section, activez d'abord le mode développeur : Paramètres → Outils du développeur → Activer le mode développeur)
  3. Cliquez sur Nouveau, donnez un nom à la clé comme typescript-integration
  4. Copiez la clé générée immédiatement — Odoo ne la montre qu'une seule fois

Collez-la dans votre fichier .env sous ODOO_API_KEY.


Étape 4 : Construction de la classe OdooClient

Créez 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(`Échec authentification : ${err.message}`));
          if (!uid) return reject(new Error("Identifiants invalides — vérifiez l'utilisateur et la clé API"));
          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(`Appel RPC ${model}.${method} échoué : ${err.message}`));
          resolve(result as T);
        }
      );
    });
  }
}

La méthode call est le coeur du système. Elle prend :

  • model — le nom du modèle Odoo, ex. res.partner, sale.order, account.move
  • method — la méthode à appeler : search_read, create, write, unlink
  • args — les arguments positionnels (toujours un tableau)
  • kwargs — les arguments nommés (champs, limite, ordre, etc.)

Étape 5 : Recherche et lecture d'enregistrements

Créez 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);
 
// Interface TypeScript pour le modèle partenaire Odoo
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> {
  // L'ID pays de l'Arabie Saoudite dans Odoo est 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(`${partners.length} entreprises saoudiennes trouvées :`);
  partners.forEach(p => {
    const country = p.country_id ? p.country_id[1] : 'Inconnu';
    console.log(`  [${p.id}] ${p.name} (${country}) — ${p.email || 'pas de mail'}`);
  });
}
 
listSaudiCompanies().catch(console.error);

Exécutez-le :

npx tsx src/main.ts

Syntaxe du filtre domaine

Les filtres Odoo utilisent des tableaux de triplets : ['champ', 'opérateur', valeur].

// Contacts d'Arabie Saoudite
[['country_id', '=', 186]]
 
// Entreprises avec un numéro de téléphone
[['is_company', '=', true], ['phone', '!=', false]]
 
// Enregistrements créés après le 2026-01-01
[['create_date', '>=', '2026-01-01 00:00:00']]
 
// Partenaires dont le nom contient "société"
[['name', 'ilike', 'société']]

Plusieurs conditions dans le même tableau sont combinées avec AND. Utilisez '|' pour le OR :

// Enregistrements dont le nom commence par "Al" ou "Al-"
['|', ['name', '=like', 'Al%'], ['name', '=like', 'Al-%']]

Étape 6 : Création d'enregistrements

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 prend une liste contenant un dictionnaire
  );
  console.log(`Partenaire créé avec l'ID ${newId}`);
  return newId;
}
 
// Exemple
const id = await createPartner({
  name: "Société Al-Amana Technologies",
  is_company: true,
  country_id: 186,
  email: 'contact@alamana-tech.sa',
  phone: '+966500000000',
});

Création avec des lignes associées (Factures)

Odoo utilise une syntaxe de commandes spéciale pour les champs One2many et Many2many. Le format est [code_commande, id, valeurs] :

  • [0, 0, values] — créer un nouvel enregistrement associé
  • [1, id, values] — modifier un enregistrement associé existant
  • [2, id, 0] — supprimer un enregistrement associé
  • [4, id, 0] — lier un enregistrement existant sans le modifier
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',  // facture client
      invoice_date: new Date().toISOString().split('T')[0],
      invoice_line_ids: lines.map(line => [0, 0, line]),
    }]]
  );
 
  console.log(`Facture n° ${invoiceId} créée`);
  return invoiceId;
}
 
await createInvoice(id, [
  { product_id: 1, quantity: 3, price_unit: 500, name: "Services de conseil en IA" },
  { product_id: 2, quantity: 1, price_unit: 1200, name: "Mise en place de l'intégration" },
]);

Étape 7 : Modification d'enregistrements

La méthode write prend une liste d'ID d'enregistrements et un dictionnaire des champs à mettre à jour :

// Mise à jour de l'email d'un partenaire
async function updatePartnerEmail(partnerId: number, email: string): Promise<void> {
  await client.call<boolean>(
    'res.partner',
    'write',
    [[partnerId], { email }]  // premier argument : liste d'IDs
  );
}
 
// Mise à jour de plusieurs enregistrements en une fois
async function markPartnersAsCustomer(ids: number[]): Promise<void> {
  await client.call<boolean>(
    'res.partner',
    'write',
    [ids, { customer_rank: 1 }]
  );
}

Étape 8 : Archivage et suppression

La plupart des enregistrements Odoo supportent la suppression douce via le champ active :

// Archivage (suppression douce) — approche recommandée
async function archiveRecord(model: string, id: number): Promise<void> {
  await client.call<boolean>(model, 'write', [[id], { active: false }]);
}
 
// Suppression définitive — à utiliser avec précaution
async function deleteRecord(model: string, id: number): Promise<void> {
  await client.call<boolean>(model, 'unlink', [[id]]);
}

Préférez l'archivage à la suppression définitive. Passer active à false masque l'enregistrement de toutes les vues par défaut tout en préservant l'historique d'audit. La suppression définitive (unlink) lève une erreur de validation si l'enregistrement a des dépendances — par exemple, vous ne pouvez pas supprimer un partenaire qui a des factures ouvertes.


Étape 9 : Utilisation de l'API REST (Odoo 17+)

Odoo 17 livre une API REST beaucoup plus conviviale pour les développeurs. Elle accepte et retourne du JSON, utilise les verbes HTTP standard, et s'authentifie 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(`Erreur Odoo REST ${response.status} sur ${path} : ${body}`);
  }
 
  return response.json() as Promise<T>;
}
 
// Lister les 10 dernières commandes de vente
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 commandes : ${ordersResult.count}`);
ordersResult.records.forEach(o => {
  console.log(`  ${o.name} — ${o.amount_total} SAR`);
});
 
// Créer un contact via REST
const newContact = await odooRestFetch<{ id: number }>(
  '/api/res.partner',
  {
    method: 'POST',
    body: JSON.stringify({
      name: "Cabinet Al-Fahd Consulting",
      is_company: true,
      country_id: 186,
    }),
  }
);
console.log(`Contact créé avec l'ID : ${newContact.id}`);

Quand utiliser REST vs XML-RPC :

  • REST — Odoo 16 ou 17, vous voulez un code plus propre avec la sémantique HTTP standard
  • XML-RPC — Odoo 15 ou antérieur, ou lors de l'appel de méthodes métier non exposées via REST

Étape 10 : Patterns de production

Retry sur les erreurs transitoires

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(`Tentative ${attempt} échouée, nouvel essai dans ${delay}ms...`);
      await new Promise(r => setTimeout(r, delay));
    }
  }
  throw new Error('Non atteignable');
}

Traitement par lots des grands ensembles de données

Odoo fonctionne mieux quand vous lisez les enregistrements par lots plutôt que de tout récupérer en une fois :

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;
  }
}

Étape 11 : Exemple concret — Synchronisation du stock

Ce pattern extrait les niveaux de stock actuels depuis Odoo et les pousse vers un catalogue externe :

interface OdooProduct {
  id: number;
  name: string;
  default_code: string | false;  // référence interne / SKU
  qty_available: number;          // quantité disponible
  virtual_available: number;      // quantité prévisionnelle
  list_price: number;
  active: boolean;
}
 
async function syncInventoryToExternalCatalog(): Promise<void> {
  console.log('Démarrage de la synchronisation du stock...');
  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);
 
    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(`${totalSynced} produits synchronisés jusqu'ici...`);
  }
 
  console.log(`Synchronisation terminée. ${totalSynced} produits mis à jour.`);
}
 
async function updateExternalCatalog(data: {
  sku: string;
  name: string;
  inStock: number;
  forecasted: number;
  priceRiyal: number;
}): Promise<void> {
  // Remplacez par votre appel au système externe
  console.log(`  Mise à jour SKU ${data.sku} : ${data.inStock} en stock`);
}
 
syncInventoryToExternalCatalog().catch(console.error);

Résolution des problèmes courants

"Access Denied" sur un modèle spécifique

L'utilisateur d'intégration n'a pas les droits d'accès pour ce modèle. Dans Odoo, allez dans Paramètres → Utilisateurs, ouvrez l'utilisateur d'intégration et vérifiez ses droits d'accès. Il est recommandé de créer un utilisateur dédié à faibles privilèges, limité aux modèles dont il a besoin.

Erreur "Expected singleton"

Vous avez passé une liste là où Odoo attend un enregistrement unique. Cela signifie généralement que votre domaine a retourné plus d'un enregistrement pour une méthode qui en attend exactement un. Ajoutez limit: 1 à votre appel search_read.

Décalage horaire sur les dates

Odoo stocke tous les datetime en UTC en interne. L'Arabie Saoudite est à UTC+3 (AST). Lors du filtrage par plages de dates, passez toujours des heures UTC pour éviter de manquer des enregistrements proches de minuit.

Limitation du débit sur Odoo SaaS

Odoo SaaS impose des quotas de requêtes par base de données. Pour les opérations en masse, ajoutez un petit délai entre les lots et évitez les rafales parallèles.


Pour aller plus loin


Conclusion

Vous disposez maintenant d'une base prête pour la production permettant d'intégrer n'importe quelle application TypeScript externe avec Odoo 17. La classe OdooClient gère l'authentification, la logique de retry et la sécurité des types — vous pouvez l'étendre à n'importe lequel des centaines de modèles Odoo en utilisant les mêmes patterns présentés ici.

Les gains d'intégration les plus importants sur le marché MENA ne viennent pas du remplacement d'Odoo, mais de sa connexion : un portail client qui lit le stock en temps réel depuis Odoo, un tableau de BI qui extrait les factures la nuit, un bot WhatsApp qui confirme les dates de livraison. Ce sont ces intégrations qui transforment un investissement ERP en avantage concurrentiel.

Besoin d'aide pour construire une intégration Odoo sur mesure ? Notre équipe a livré des projets d'intégration API en Arabie Saoudite, dans le Golfe et en Afrique du Nord — de simples synchronisations de stock jusqu'à des pipelines d'automatisation multi-systèmes complets. Contactez-nous pour une évaluation technique sans engagement.