← Blog
odooinventorypurchase-orderintegrationjson-rpc

Receive Inventory from a Purchase Order via the Odoo API

A hands-on guide to receiving stock against a confirmed purchase order through the Odoo API: find the receipt, set quantities, and validate — one call_method away.

ODXProxy Team · Jul 22, 2026 · 9 min read

Receive Inventory from a Purchase Order via the Odoo API — ODXProxy blog cover

You have a confirmed purchase order in Odoo and the goods just landed on the dock. Now something outside Odoo — a barcode scanner app, a supplier's advance-shipping-notice webhook, a warehouse management system — needs to receive that stock against the purchase order without a human opening the Odoo UI. This is the classic integration chore, and it turns out to be a lot simpler than it looks: once you know which record to touch, receiving a purchase order over the Odoo API comes down to one method call. This guide walks the whole flow through ODXProxy, from finding the receipt to validating it, with runnable JSON-RPC at every step.

What "receiving" actually is in Odoo

When you confirm a purchase.order, Odoo's inventory module creates an incoming transfer — a stock.picking record with picking_type_code of "incoming", linked back to the PO. That picking holds one stock.move per ordered product, each carrying a demand quantity (product_uom_qty) copied from the order line.

"Receiving the goods" is two things done to that picking:

  1. Record how much actually arrived (the done quantity on each move).
  2. Validate the transfer, which posts the stock and updates the PO's received quantities.

So the entire job is: find the picking, set the quantities, call one validation method. Everything below is a variation on those three steps.

Flow: your app calls ODXProxy, which finds the receipt picking, sets quantities, and validates it to done

This article assumes the PO is already confirmed and its receipt picking exists. Confirming a draft PO is itself a call_method call — button_confirm on purchase.order — following exactly the same pattern shown here.

Step 1 — find the receipt picking for the PO

Start from the purchase order. A confirmed PO exposes its transfers in the picking_ids field, so a single search_read gets you the PO and the receipt ids in one round trip. Say the PO reference is P00042:

{
  "id": "po-lookup-1",
  "action": "search_read",
  "model_id": "purchase.order",
  "params": [[["name", "=", "P00042"]]],
  "keyword": { "fields": ["name", "state", "picking_ids"] },
  "odoo_instance": {
    "url": "https://erp.example.com",
    "db": "prod",
    "user_id": 2,
    "api_key": "<the Odoo user API key>"
  }
}

The result gives you the PO id, its state (you want "purchase" — a confirmed order), and a list of picking ids:

{
  "jsonrpc": "2.0",
  "id": "po-lookup-1",
  "result": [
    { "id": 42, "name": "P00042", "state": "purchase", "picking_ids": [128] }
  ]
}

A PO can have more than one receipt (partial deliveries create backorders), so read the pickings and keep the one that is incoming and not yet done:

{
  "id": "picking-read-1",
  "action": "search_read",
  "model_id": "stock.picking",
  "params": [[["id", "in", [128]], ["picking_type_code", "=", "incoming"], ["state", "!=", "done"]]],
  "keyword": { "fields": ["name", "state", "origin", "move_ids"] }
}

That returns the transfer to work on — for example { "id": 128, "name": "WH/IN/00031", "state": "assigned", "move_ids": [301, 302] }. Hold onto the picking id (128) and its move ids.

On Odoo 15 and earlier the picking's moves live in move_lines, not move_ids — the field was renamed in Odoo 16. This is exactly the kind of thing to confirm against your instance rather than assume; see the next step.

Step 2 — ask Odoo which quantity field to write

Here is the one place people get burned. The field that stores the done quantity on a stock move was renamed across Odoo versions: it is quantity on Odoo 17 and 18, and quantity_done on Odoo 16 and earlier. Guessing wrong means you either write to a field that no longer exists or silently set nothing.

Don't guess — ask the model. fields_get describes the real schema of the target instance, which is the reliable way to write integration code that survives an upgrade:

{
  "id": "fields-1",
  "action": "fields_get",
  "model_id": "stock.move",
  "params": [],
  "keyword": { "attributes": ["string", "type"] }
}

Scan the result for the done-quantity field (quantity or quantity_done) and, on Odoo 17+, the picked boolean. Introspecting first is the single most effective habit for Odoo integrations, because instances are heavily customized and version-dependent. The examples below use the Odoo 17+ names; swap in quantity_done if fields_get tells you you're on an older release.

Step 3 — set the received quantity

Now write how much arrived onto each move. To receive the order in full, set the done quantity equal to the demand you already read. On Odoo 17+ you also flag the move as picked so validation counts it:

{
  "id": "set-qty-1",
  "action": "write",
  "model_id": "stock.move",
  "params": [[301], { "quantity": 100, "picked": true }],
  "keyword": {}
}

write takes the id list as the first positional argument and the values object as the second — here, move 301 receives a done quantity of 100. Repeat for each move on the picking (or send all the ids in one write if every line arrived in full and shares the same value). If you received less than was ordered, set the smaller number — that's what triggers the backorder path in Step 5.

Writing the demand quantity onto product_uom_qty is a different thing entirely — that changes what was ordered, not what was received. Receiving only ever touches the done-quantity field (quantity / quantity_done).

Step 4 — the one call that receives the stock

With quantities set, receiving is a single method call: button_validate on the picking. It isn't CRUD, so it goes through call_method with an explicit fn_name. Like every recordset method, the picking id list is the first positional argument:

{
  "id": "validate-1",
  "action": "call_method",
  "model_id": "stock.picking",
  "fn_name": "button_validate",
  "params": [[128]],
  "keyword": {}
}

The same call as raw HTTP, showing 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": "validate-1",
    "action": "call_method",
    "model_id": "stock.picking",
    "fn_name": "button_validate",
    "params": [[128]],
    "keyword": {},
    "odoo_instance": {
      "url": "https://erp.example.com", "db": "prod",
      "user_id": 2, "api_key": "<the Odoo user API key>"
    }
  }'

When you received everything in full, button_validate completes the transfer and returns true. The picking flips to state: "done", stock is posted, and the PO's received quantities update automatically. That's the whole job — one action fired from anywhere your app runs.

If you call button_validate with no done quantity set on any move, Odoo raises a UserError ("You cannot validate a transfer if no quantities are reserved..."). That comes back as an HTTP 200 with a populated error object — see the two-step check below. Always set quantities in Step 3 first.

For the mechanics of why params starts with a list of ids, and what call_method can return, see calling custom Odoo methods with call_method.

Step 5 — handle the partial receipt (backorder)

If the done quantity on any move is less than the demand, Odoo doesn't just complete the transfer — it asks what to do with the remainder. Over the API, that question comes back as button_validate returning an action dictionary instead of true:

{
  "jsonrpc": "2.0",
  "id": "validate-1",
  "result": {
    "type": "ir.actions.act_window",
    "res_model": "stock.backorder.confirmation",
    "target": "new",
    "context": { "default_pick_ids": [[4, 128]], "button_validate_picking_ids": [128] }
  }
}

That res_model is the wizard Odoo would show a human. To answer it programmatically, create the wizard record with the context Odoo handed you, then call its process method to confirm the backorder (Odoo will validate what arrived and create a new draft receipt for the rest):

{
  "id": "backorder-1",
  "action": "create",
  "model_id": "stock.backorder.confirmation",
  "params": [{ "pick_ids": [[6, 0, [128]]] }],
  "keyword": { "context": { "button_validate_picking_ids": [128] } }
}

Then call process on the id that create returns:

{
  "id": "backorder-2",
  "action": "call_method",
  "model_id": "stock.backorder.confirmation",
  "fn_name": "process",
  "params": [[57]],
  "keyword": {}
}

To validate the arrived quantity and not keep a backorder for the rest, call process_cancel_backorder instead. The wizard's exact fields shift between versions, so this is another good spot to run fields_get on stock.backorder.confirmation before wiring it up.

Prefer to avoid the wizard entirely? Only ever receive in full in Step 3 (done quantity = demand). No shortfall means no backorder question, and button_validate returns true straight away.

The HTTP 200 trap applies throughout

Receiving runs real business logic — access-right checks, lot/serial requirements, negative-stock guards — so it's more prone than a plain read to hit an Odoo validation rule. Every one of those comes back as an Odoo logic error with HTTP 200 and a populated error object, never a non-200 status. Trying to validate an already-done picking, for instance:

{
  "jsonrpc": "2.0",
  "id": "validate-1",
  "error": {
    "code": 200,
    "message": "This transfer has already been validated.",
    "data": { "name": "odoo.exceptions.UserError" }
  }
}

So the two-step check is mandatory: confirm the HTTP status first (a non-200 like -32004 is a proxy-layer or gateway problem), then — even on a 200 — check for a populated error before trusting result. The full pattern and a reusable handler are in Odoo API error handling.

Step 6 — verify the receipt landed

Finally, confirm the outcome by reading the picking state and the PO's received quantities. The picking should be done; the order lines carry qty_received:

{
  "id": "verify-1",
  "action": "search_read",
  "model_id": "purchase.order.line",
  "params": [[["order_id", "=", 42]]],
  "keyword": { "fields": ["product_id", "product_qty", "qty_received"] }
}

When qty_received matches product_qty on every line, the purchase order is fully received. On Odoo 17+ the order itself also exposes a receipt_status field (pending / partial / full) if you'd rather check one value than compare lines.

Putting it together

Receiving inventory against a purchase order over the API is a short, dependable sequence:

  1. Find the incoming stock.picking from the PO's picking_ids.
  2. Introspect the done-quantity field with fields_get (quantity on 17+, quantity_done before).
  3. Write the received quantity (and picked: true on 17+) onto each stock.move.
  4. Validate with a single call_methodbutton_validate.
  5. Answer the backorder wizard only if you received a shortfall.
  6. Verify via qty_received on the order lines.

The heavy lifting is one method call; the rest is knowing which record to point it at. That's the shape of most Odoo inventory automation — reserving, delivering, scrapping, and internal transfers all follow the same find-set-validate rhythm.