← Blog
odooapicall-methodjson-rpc

Calling Custom Odoo Methods with call_method

The eight direct actions cover CRUD and queries. For everything else — action_post, name_search, custom business logic — you use call_method. Here's how.

ODXProxy Team · Jul 4, 2026 · 6 min read

Calling Custom Odoo Methods with call_method — ODXProxy blog cover

Sooner or later, reading and writing records isn't enough — you need to do something in Odoo: confirm a sale order, post an invoice, run a wizard, call a bit of custom business logic your team wrote. None of those are CRUD, so none of them have a dedicated action. That's what call_method is for: it's the escape hatch that lets you invoke any Odoo model method by name. This guide shows how to call custom Odoo methods through the proxy, what goes in params versus keyword, and the one required field that everyone forgets.

When you need call_method

ODXProxy exposes nine actions. Eight of them are fixed operations — search_count, search, read, fields_get, search_read, create, write, unlink — and they cover querying and CRUD. The ninth, call_method, is the generic one: it forwards a call to any method on the model, named by you.

The rule of thumb is simple: if one of the eight direct actions does the job, use it — it's clearer and self-documenting. Reach for call_method when the thing you want isn't CRUD:

  • Workflow transitionsaction_confirm on a sale.order, action_post on an account.move.
  • Built-in helpersname_search, default_get, copy, name_get.
  • Chatter and messagingmessage_post to add a note to a record's log.
  • Your own custom methods — anything a custom module defines on its models.

call_method flows from your app through ODXProxy to an Odoo model method named by fn_name

The shape of a call_method request

A call_method request looks like any other, with two differences: action is "call_method", and you must supply a non-empty fn_name — the actual Odoo method to invoke. Here's how you post an invoice (account.move.action_post):

{
  "id": "post-1",
  "action": "call_method",
  "model_id": "account.move",
  "fn_name": "action_post",
  "params": [[128]],
  "keyword": {},
  "odoo_instance": {
    "url": "https://erp.example.com",
    "db": "prod",
    "user_id": 2,
    "api_key": "<the Odoo user API key>"
  }
}

The same call as raw HTTP, with the two distinct credentials — the proxy's x-api-key header and the Odoo user's api_key in the body:

curl -X POST https://your-proxy.example.com/api/odoo/execute \
  -H "Content-Type: application/json" \
  -H "x-api-key: $ODX_PROXY_KEY" \
  -d '{
    "id": "post-1",
    "action": "call_method",
    "model_id": "account.move",
    "fn_name": "action_post",
    "params": [[128]],
    "keyword": {},
    "odoo_instance": {
      "url": "https://erp.example.com", "db": "prod",
      "user_id": 2, "api_key": "<the Odoo user API key>"
    }
  }'
If action is "call_method" but fn_name is missing or empty, the proxy rejects the request before it ever reaches Odoo with HTTP 400 and JSON-RPC code -32002. This is a proxy-layer failure, so it comes back with a non-200 status — one of the few Odoo-related errors that does.

Why params starts with a list of ids

Odoo methods that operate on records are methods on a recordset — they run against a set of records. When the call is marshalled over the wire, that recordset becomes the first positional argument: the list of ids the method should act on. That's why params above is [[128]] — the argument list contains one argument, the id list [128].

Everything after that first id-list argument is the method's own further arguments, in order. Calling name_search, for instance, takes a search string as its first real argument and options as keywords:

{
  "id": "ns-1",
  "action": "call_method",
  "model_id": "res.partner",
  "fn_name": "name_search",
  "params": ["Azure"],
  "keyword": { "limit": 8 }
}

Here there's no id list because name_search is a class-level lookup, not something you run on specific records — its first positional argument is the search term. The shape of params always mirrors the method's own signature:

  • params — positional arguments, in order. For record methods, the first is the id list.
  • keyword — keyword arguments as a JSON object (default {}).

When in doubt, check the method's Python signature in the Odoo source or your custom module: whatever comes after self maps directly onto params and keyword.

Passing context

Many Odoo methods change behavior based on context — the language, company, or custom flags in play. You pass it as a context object inside keyword. For example, creating a record in a specific language while suppressing the change-tracking log entries:

{
  "id": "ctx-1",
  "action": "call_method",
  "model_id": "product.template",
  "fn_name": "copy",
  "params": [[55]],
  "keyword": { "context": { "lang": "fr_FR", "tracking_disable": true } }
}

copy duplicates record 55; the context tells Odoo which language to translate labels into and, with tracking_disable, to skip writing chatter entries for the new record.

Return values vary — read the method

Unlike the CRUD actions, which have predictable returns, call_method gives back whatever the method returns, verbatim, in result. That might be:

  • a boolean (action_post returns true on success),
  • a list of [id, name] pairs (name_search),
  • an action dict describing a window to open (many action_* methods on wizards), or
  • nothing meaningful at all.
{
  "jsonrpc": "2.0",
  "id": "ns-1",
  "result": [[14, "Azure Interior"], [9, "Azure Furniture Co."]]
}

Because the shape isn't fixed, always check the specific method's documentation or source for what it returns rather than assuming.

The HTTP 200 trap applies here too

call_method runs arbitrary business logic, which means it's more likely than a plain read to hit a validation rule, a workflow guard, or a UserError raised on purpose. All of those come back as Odoo logic errors with an HTTP 200 and a populated error object — trying to post an already posted invoice, for example:

{
  "jsonrpc": "2.0",
  "id": "post-1",
  "error": {
    "code": 200,
    "message": "This move is already posted.",
    "data": { "name": "odoo.exceptions.UserError" }
  }
}

So the two-step check is non-negotiable: verify the HTTP status first (a non-200 like -32002 is a proxy-layer problem), then — even on a 200 — check for a populated error before reading result. The complete two-layer pattern and a reusable handler are in Odoo API error handling.

Where to go next

call_method is how you reach past CRUD into Odoo's real business logic: set action to "call_method", name the method in fn_name (required, non-empty), put the id list first in params, and read the method's own docs for what comes back.