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

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:
- Record how much actually arrived (the done quantity on each move).
- 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.

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.
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.
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.
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.
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:
- Find the incoming
stock.pickingfrom the PO'spicking_ids. - Introspect the done-quantity field with
fields_get(quantityon 17+,quantity_donebefore). - Write the received quantity (and
picked: trueon 17+) onto eachstock.move. - Validate with a single
call_method→button_validate. - Answer the backorder wizard only if you received a shortfall.
- Verify via
qty_receivedon 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.
- This is one spoke of our Odoo integration work — see also keeping external systems in sync with Odoo and driving Odoo from webhooks.
- Get the ids to act on with the Odoo search_read example, and reach past CRUD with call_method.
- See the allowed actions and the
fn_namerule in the actions reference, and the full request envelope in the API reference. - Let the Python SDK handle the envelope so your code is just
session.call_method("button_validate", ...).