Ad – 728×90
🦉 Odoo Integration

OWL JS RPC – Calling Python from Your OWL Components

Every OWL component in Odoo eventually needs to communicate with the Python backend — fetching records, saving changes, triggering business logic, or calling custom methods. Odoo provides two paths for this: the orm service for all standard model operations (preferred), and the rpc service for custom controllers and non-ORM endpoints. This lesson covers both paths in depth: domain syntax, ORM method signatures, custom Python method calls, controller routes, and robust error handling for server-side errors.

⏱️ 22 min read 🎯 Intermediate 📅 Updated 2026 👁️ Lesson 3 of 3

orm Service – The Right Tool for Model Calls

The orm service wraps Odoo's JSON-RPC ORM endpoint (/web/dataset/call_kw) with a clean, typed API. Always prefer it over raw rpc for model operations.

JavaScript – orm service setup
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";

setup() {
  this.orm = useService("orm");
}

Odoo Domain Syntax in JavaScript

Domains are arrays of conditions. Each condition is a 3-tuple: [fieldName, operator, value]. Conditions are AND'd by default; prefix with "&" or "|" for explicit operators.

JavaScript – domain examples
// Simple AND (default): all conditions must match
const domain = [
  ["is_company", "=", true],
  ["active",     "=", true],
  ["country_id", "=", 233],   // 233 = United States ID
];

// OR: either condition matches (prefix with "|")
const orDomain = [
  "|",
    ["state", "=", "draft"],
    ["state", "=", "sent"],
];

// Mixed AND/OR
const mixedDomain = [
  "&",
    ["is_company", "=", true],
    "|",
      ["country_id", "=", 233],
      ["country_id", "=", 76],   // France
];

// Common operators
// "="   "!="   "<"   ">"   "<="   ">="
// "like"   "ilike"  (case-insensitive)
// "in"     "not in"  — value is an array
// "child_of"  "parent_of"  — hierarchical
// "="  false  or  "=", []  — empty/unset many2many

const searchDomain = [["name", "ilike", this.state.searchQuery]];
const inDomain = [["id", "in", [1, 5, 12, 44]]];
const dateDomain = [
  ["date_order", ">=", "2026-01-01"],
  ["date_order", "<",  "2027-01-01"],
];

searchRead – The Most Common ORM Call

JavaScript – searchRead full signature
const records = await this.orm.searchRead(
  "sale.order",           // model technical name
  [                       // domain (array of conditions)
    ["state", "in", ["draft", "sent"]],
    ["user_id", "=", this.user.userId],
  ],
  [                       // fields to fetch (empty array = all)
    "name", "partner_id", "amount_total", "state", "date_order"
  ],
  {
    limit:   50,           // max records (default: no limit in some contexts)
    offset:  0,            // for pagination
    order:   "date_order desc",  // sort
    context: {},           // additional context dict
  }
);
// Returns: [{ id: 1, name: "S001", partner_id: [5, "Alice"], ... }, ...]
// Note: many2one fields return [id, display_name]
Ad – 336×280

Calling Custom Python Methods

JavaScript + Python – orm.call
// JavaScript: call a custom Python model method
const result = await this.orm.call(
  "sale.order",           // model
  "action_confirm",       // Python method name
  [[orderId]],            // positional args — first must be the IDs list
  { context: {} }         // optional keyword args
);

// For a classmethod (no IDs):
const stats = await this.orm.call(
  "sale.order",
  "get_sales_stats",
  [],                     // empty ids list
  { date_from: "2026-01-01", date_to: "2026-12-31" }
);
Python – the matching model method
from odoo import models, api
from odoo.exceptions import UserError

class SaleOrder(models.Model):
    _inherit = "sale.order"

    def action_confirm(self):
        # 'self' is a recordset of the IDs passed from JS
        for order in self:
            if not order.order_line:
                raise UserError("Cannot confirm an order with no lines.")
        return super().action_confirm()

    @api.model
    def get_sales_stats(self, date_from, date_to):
        # @api.model: called without a recordset (empty IDs list from JS)
        orders = self.search([
            ("date_order", ">=", date_from),
            ("date_order", "<",  date_to),
            ("state",      "=",  "sale"),
        ])
        return {
            "count":   len(orders),
            "revenue": sum(orders.mapped("amount_total")),
        }

Handling Server Errors

JavaScript – server error structure
async confirmOrder(orderId) {
  try {
    await this.orm.call("sale.order", "action_confirm", [[orderId]]);
    this.notification.add("Order confirmed!", { type: "success" });
  } catch (err) {
    // Odoo error structure:
    // err.message      — generic "Odoo Server Error"
    // err.data.name    — "odoo.exceptions.UserError"
    // err.data.message — human-readable message from Python

    if (err.data?.name === "odoo.exceptions.UserError") {
      // Show the message from the Python UserError
      this.notification.add(err.data.message, { type: "warning" });
    } else if (err.data?.name === "odoo.exceptions.AccessError") {
      this.notification.add("You do not have permission for this action.", { type: "danger" });
    } else {
      // Unexpected server error — show generic message + log
      console.error("Unexpected error:", err);
      this.notification.add("An unexpected error occurred. Please contact your administrator.", {
        type:   "danger",
        sticky: true,
      });
    }
  }
}

rpc Service – Custom Controllers

For non-ORM endpoints — custom JSON/HTTP controllers you wrote — use the lower-level rpc service.

JavaScript + Python – custom controller call
// JavaScript
setup() {
  this.rpc = useService("rpc");
}

async getDashboardData() {
  const data = await this.rpc("/my_addon/dashboard_data", {
    company_id: this.user.activeCompany.id,
  });
  this.state.stats = data;
}
Python – matching controller
from odoo import http
from odoo.http import request
import json

class DashboardController(http.Controller):

    @http.route("/my_addon/dashboard_data", type="json", auth="user")
    def dashboard_data(self, company_id=None, **kwargs):
        env = request.env
        orders = env["sale.order"].search_count([
            ["company_id", "=", company_id],
            ["state",      "=", "sale"],
        ])
        return {
            "confirmed_orders": orders,
            "revenue": env["sale.order"].search([
                ["company_id", "=", company_id],
                ["state",      "=", "sale"],
            ]).mapped("amount_total"),
        }

📋 Summary

  • Use orm service for all standard model operations. Use rpc service only for custom controller routes.
  • Domains are arrays of [field, operator, value] triples. Use "|" prefix for OR, "&" for explicit AND.
  • orm.searchRead(model, domain, fields, options) — most common call. Many2one fields return [id, name] arrays.
  • orm.call(model, method, [ids], kwargs) — first positional arg must be the IDs list; empty list for @api.model methods.
  • Server errors carry err.data.name (exception class) and err.data.message (human text). Always check data.name to distinguish UserError from access errors from unexpected errors.
  • Custom controllers: type="json", auth="user". Call via rpc("/route", payload).

🏋️ Exercise

Build a complete Odoo OWL component that reads and writes via the backend:

  1. Create a Python model method get_overdue_invoices() that returns a count and total amount of overdue invoices for the current user's company.
  2. Create an OWL component that calls it via orm.call in onWillStart and displays the stats.
  3. Add a "Send Reminder" button that calls orm.call with an account.move method to email reminders. Handle UserError gracefully.
  4. Add a domain filter (All / This Month / Overdue) that changes the searchRead domain and re-fetches the list without remounting.
  5. Use notification.add for success/failure feedback after every backend call.

FAQ

What is the difference between orm.call and rpc directly? +

orm.call internally calls /web/dataset/call_kw with the model/method/args structure that Odoo's ORM expects. It handles authentication, CSRF tokens, and session cookies for you. Direct rpc("/custom/route", data) calls your custom URL with a raw POST — you control the route and response format. Use orm.call for everything that goes through the Odoo ORM. Use raw rpc only for non-ORM endpoints.

Why do many2one field values come back as [id, name] arrays? +

Odoo's ORM always returns many2one fields as a 2-tuple [id, display_name] — e.g., partner_id: [5, "Alice Corp"]. To get just the ID use record.partner_id[0]; for the name use record.partner_id[1]. If you request a field like partner_id.name (dot notation) in the fields list, Odoo returns it as a flat string instead of the tuple. This is useful when you only need the name.