Key Takeaways: Stockout alerts don’t belong inside Odoo customizations — an external scheduler like n8n is the cleaner fit. The hard part isn’t querying
stock.quant; it’s deciding which threshold logic matches how your client actually manages reorders. Always consolidate before alerting: one grouped message per warehouse beats one message per SKU. Fifteen-minute polling is a good starting point; a morning digest works better for operations teams that plan replenishment daily rather than reactively. The full n8n workflow JSON is included at the end.
Stockouts have a pattern. They don’t announce themselves — they accumulate quietly while the team is focused elsewhere. By the time someone checks the shelf or opens a picking order, the reorder window has already passed.
This tutorial builds a low-stock alert system that polls Odoo Inventory on a schedule, evaluates stock levels against reorder thresholds, groups the results by warehouse, and sends one consolidated notification per run. The whole thing takes under an hour to set up.
No Odoo customizations. No new modules. The stack: Odoo 18 (standard Inventory module), n8n, and either Slack or your existing mail server.
What We’re Building
The workflow runs on a schedule — every 15 minutes for reactive operations teams, or once daily at 7:00 AM for teams that plan replenishment at the start of each day.
On each run it:
- Queries
stock.quantvia JSON-RPC to get current on-hand quantities - Compares against thresholds — either the product’s own
reorder_min_qtyor a global fallback - Filters to products below threshold, grouped by warehouse
- Sends one consolidated Slack message or email per warehouse (not one per SKU)
The consolidation step is non-negotiable. An alert system that sends 40 messages for 40 products gets turned off within a week.
Setting Up Odoo Authentication
Use an API key, not a password. In Odoo, go to Settings > Users & Companies > Users, open the relevant user, navigate to Account Security, and generate a new API key.
You also need the user’s numeric ID. Check the URL when the user record is open:
https://your-odoo.com/odoo/settings/users/6
That trailing number (6) is the user ID you’ll pass in every JSON-RPC call. Confirm it matches the user you generated the API key for — a mismatched user ID causes silent failures that are frustrating to debug.
Step 1: Fetch Current Stock Levels
Add an HTTP Request node in n8n. Send a POST to:
https://your-odoo-url.com/jsonrpc
Call search_read on stock.quant:
{
"jsonrpc": "2.0",
"method": "call",
"params": {
"service": "object",
"method": "execute_kw",
"args": [
"your-db-name",
6,
"your-api-key",
"stock.quant",
"search_read",
[[
["location_id.usage", "=", "internal"],
["quantity", ">", -1]
]],
{
"fields": ["product_id", "location_id", "quantity", "reserved_quantity"],
"limit": 500
}
]
}
}
The domain ["location_id.usage", "=", "internal"] restricts results to internal stock locations — this excludes transit locations, virtual locations, and supplier/customer locations that would pollute the results.
What to do if your catalogue is large: If you have more than 500 active products per warehouse, set limit to 0 (no limit) or paginate using offset. For most SME deployments, 500 is fine.
Gotcha: quantity in stock.quant is the on-hand quantity. reserved_quantity is what’s already committed to outgoing pickings. The available quantity — what you can actually pick — is quantity - reserved_quantity. Use this for your threshold comparison if the business cares about available stock, not just physical stock.
Step 2: Fetch Reorder Rules
A second HTTP Request node fetches the reorder rules defined in stock.warehouse.orderpoint:
{
"jsonrpc": "2.0",
"method": "call",
"params": {
"service": "object",
"method": "execute_kw",
"args": [
"your-db-name",
6,
"your-api-key",
"stock.warehouse.orderpoint",
"search_read",
[[]],
{
"fields": ["product_id", "warehouse_id", "product_min_qty", "product_max_qty", "active"],
"domain": [["active", "=", true]]
}
]
}
}
This gives you per-product, per-warehouse reorder rules. If a product has no orderpoint, you’ll fall back to a global threshold (discussed below).
Step 3: Apply Threshold Logic in a Code Node
Add a Code node. This is where the real logic lives.
const quants = $('Fetch Stock Levels').all();
const orderpoints = $('Fetch Reorder Rules').all();
// Build a lookup map: "product_id|warehouse_id" -> min_qty
const thresholdMap = {};
const DEFAULT_THRESHOLD = 5; // fallback for products with no orderpoint
for (const op of orderpoints) {
const d = op.json;
const key = `${d.product_id[0]}|${d.warehouse_id[0]}`;
thresholdMap[key] = {
min_qty: d.product_min_qty,
max_qty: d.product_max_qty,
warehouse_name: d.warehouse_id[1]
};
}
// Group low-stock items by warehouse
const alertsByWarehouse = {};
for (const q of quants) {
const d = q.json;
const productId = d.product_id[0];
const productName = d.product_id[1];
const locationName = d.location_id[1];
// Derive warehouse from location name (e.g. "WH/Stock" -> "WH")
const warehouseCode = locationName.split('/')[0];
const available = d.quantity - d.reserved_quantity;
// Try per-product threshold first, fall back to global default
// We match on product+warehouse; if not found, use DEFAULT_THRESHOLD
const opKey = Object.keys(thresholdMap).find(k => k.startsWith(`${productId}|`));
const threshold = opKey ? thresholdMap[opKey].min_qty : DEFAULT_THRESHOLD;
const maxQty = opKey ? thresholdMap[opKey].max_qty : DEFAULT_THRESHOLD * 3;
if (available <= threshold) {
if (!alertsByWarehouse[warehouseCode]) {
alertsByWarehouse[warehouseCode] = [];
}
alertsByWarehouse[warehouseCode].push({
product: productName,
location: locationName,
available: available.toFixed(0),
threshold: threshold,
max_qty: maxQty,
has_orderpoint: !!opKey
});
}
}
// Emit one item per warehouse
const results = [];
for (const [warehouse, items] of Object.entries(alertsByWarehouse)) {
results.push({
json: {
warehouse,
count: items.length,
items,
}
});
}
// If nothing is low, emit a sentinel so downstream nodes know
if (results.length === 0) {
return [{ json: { warehouse: null, count: 0, items: [] } }];
}
return results;
Why per-product rules beat a global threshold: A product with a 30-day lead time and high sales velocity needs a much higher reorder point than a slow-moving consumable. Blanket thresholds generate noise for low-velocity items and miss the signal on high-velocity ones. Using stock.warehouse.orderpoint lets you piggyback on the logic your operations team already maintains in Odoo.
Gotcha: If the warehouse code derivation from location_id.name doesn’t match your Odoo setup (some instances use full paths like My Warehouse/Stock), log a few raw location names first and adjust the split logic accordingly.
Step 4: Add a No-Alert Gate
Before sending anything, add an IF node that checks:
{{ $json.count }} is greater than 0
Route the false branch to a NoOp node (or just leave it unconnected). This prevents empty alerts from firing when everything is well-stocked — which happens on most runs if your reorder points are calibrated correctly.
Step 5: Send the Alert
Option A: Slack
Add a Slack node with the Send Message operation. In the message body:
// Build the Slack message in a preceding Code node
const warehouse = $json.warehouse;
const items = $json.items;
const lines = items.map(i => {
const tag = i.has_orderpoint ? '' : ' _(no orderpoint — using default threshold)_';
return `• *${i.product}* — available: ${i.available} | min: ${i.threshold}${tag}`;
}).join('\n');
return [{
json: {
text: `:warning: *Low Stock Alert — ${warehouse}* (${items.length} product${items.length > 1 ? 's' : ''})\n${lines}`
}
}];
Post to a dedicated #stock-alerts channel rather than a general ops channel — alert fatigue kills the value of any automated monitoring.
Option B: Consolidated Email via Odoo
If you prefer routing through Odoo’s mail infrastructure (same pattern as the uninvoiced sales order alert), call mail.mail create + send:
// Code node to build HTML
const warehouse = $json.warehouse;
const items = $json.items;
const rows = items.map(i => `
<tr>
<td>${i.product}</td>
<td>${i.location}</td>
<td style="color:red">${i.available}</td>
<td>${i.threshold}</td>
<td>${i.max_qty}</td>
</tr>
`).join('');
const html = `
<h3>Low Stock Alert — ${warehouse}</h3>
<p><strong>${items.length} product(s)</strong> below reorder threshold as of ${new Date().toLocaleString()}.</p>
<table border="1" cellpadding="6" cellspacing="0">
<thead>
<tr>
<th>Product</th>
<th>Location</th>
<th>Available Qty</th>
<th>Min Qty</th>
<th>Max Qty</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
<p style="color:#888;font-size:12px">Products marked with no orderpoint use a default threshold of 5 units.</p>
`;
return [{ json: { html_summary: html, warehouse } }];
Then pass html_summary into the body_html field of the mail.mail create call. One email per warehouse per run.
Scheduling: Every 15 Minutes vs. Daily Digest
Two patterns work well depending on how the operations team manages replenishment:
Every 15 minutes — best for:
- High-velocity warehouses where a stockout in the morning shift affects afternoon picks
- Teams that reorder from local suppliers with same-day delivery
Daily digest at 07:00 — best for:
- Teams that batch replenishment orders once per day
- Products with multi-day lead times where a same-hour alert adds no value
- Reducing Slack noise in environments where alerts compete with other operational messages
You can run both: a 15-minute workflow that only fires on products marked as urgent (you can add a boolean field or use a specific orderpoint category), and a daily digest for everything else.
Full n8n Workflow JSON
Import this directly into your n8n instance via Settings > Import Workflow:
{
"name": "Odoo Low Stock Alert",
"nodes": [
{
"parameters": { "rule": { "interval": [{ "field": "minutes", "minutesInterval": 15 }] } },
"name": "Schedule",
"type": "n8n-nodes-base.scheduleTrigger",
"position": [240, 300]
},
{
"parameters": {
"url": "https://YOUR_ODOO_URL/jsonrpc",
"method": "POST",
"sendBody": true,
"bodyContentType": "json",
"jsonBody": "={\"jsonrpc\":\"2.0\",\"method\":\"call\",\"params\":{\"service\":\"object\",\"method\":\"execute_kw\",\"args\":[\"YOUR_DB\",YOUR_USER_ID,\"YOUR_API_KEY\",\"stock.quant\",\"search_read\",[[[ \"location_id.usage\",\"=\",\"internal\"]]],{\"fields\":[\"product_id\",\"location_id\",\"quantity\",\"reserved_quantity\"],\"limit\":500}]}}"
},
"name": "Fetch Stock Levels",
"type": "n8n-nodes-base.httpRequest",
"position": [460, 300]
},
{
"parameters": {
"url": "https://YOUR_ODOO_URL/jsonrpc",
"method": "POST",
"sendBody": true,
"bodyContentType": "json",
"jsonBody": "={\"jsonrpc\":\"2.0\",\"method\":\"call\",\"params\":{\"service\":\"object\",\"method\":\"execute_kw\",\"args\":[\"YOUR_DB\",YOUR_USER_ID,\"YOUR_API_KEY\",\"stock.warehouse.orderpoint\",\"search_read\",[[[ \"active\",\"=\",true]]],{\"fields\":[\"product_id\",\"warehouse_id\",\"product_min_qty\",\"product_max_qty\"]}]}}"
},
"name": "Fetch Reorder Rules",
"type": "n8n-nodes-base.httpRequest",
"position": [460, 460]
},
{
"parameters": { "jsCode": "// Paste the threshold logic Code node body here" },
"name": "Apply Threshold Logic",
"type": "n8n-nodes-base.code",
"position": [680, 380]
},
{
"parameters": {
"conditions": { "number": [{ "value1": "={{ $json.count }}", "operation": "larger", "value2": 0 }] }
},
"name": "Has Alerts?",
"type": "n8n-nodes-base.if",
"position": [900, 380]
},
{
"parameters": { "jsCode": "// Paste the Slack message builder here" },
"name": "Build Slack Message",
"type": "n8n-nodes-base.code",
"position": [1120, 300]
},
{
"parameters": {
"channel": "#stock-alerts",
"text": "={{ $json.text }}"
},
"name": "Send Slack Alert",
"type": "n8n-nodes-base.slack",
"position": [1340, 300]
}
],
"connections": {
"Schedule": { "main": [[{ "node": "Fetch Stock Levels", "type": "main", "index": 0 }, { "node": "Fetch Reorder Rules", "type": "main", "index": 0 }]] },
"Fetch Stock Levels": { "main": [[{ "node": "Apply Threshold Logic", "type": "main", "index": 0 }]] },
"Fetch Reorder Rules": { "main": [[{ "node": "Apply Threshold Logic", "type": "main", "index": 1 }]] },
"Apply Threshold Logic": { "main": [[{ "node": "Has Alerts?", "type": "main", "index": 0 }]] },
"Has Alerts?": { "main": [[{ "node": "Build Slack Message", "type": "main", "index": 0 }]] },
"Build Slack Message": { "main": [[{ "node": "Send Slack Alert", "type": "main", "index": 0 }]] }
}
}
Replace YOUR_ODOO_URL, YOUR_DB, YOUR_USER_ID, and YOUR_API_KEY before importing. Paste the full Code node bodies from the sections above into the two Code nodes.
Common Issues
All products appear below threshold. Your DEFAULT_THRESHOLD is too high for low-velocity products, or you’re using quantity instead of available (quantity - reserved_quantity). Check a few products manually in Odoo’s Inventory > Products view to calibrate.
No alerts even when stock is genuinely low. The location_id.usage = internal filter might be excluding your actual storage locations if they’re set up as a non-standard usage type. Print a sample of raw location records and verify.
Duplicate alerts per product. stock.quant can return multiple records for the same product in the same location (e.g., different lots or serial numbers). Sum the quantities before comparing to threshold: group by product_id first, then compare the aggregate.
Slack alerts fire but email doesn’t. Check that the Odoo user has mail.mail create permissions — this sometimes requires a small access rights adjustment on heavily locked-down instances.
Where to Take This Next
This workflow handles the common case well. Extensions worth considering:
- Lot/serial number tracking: filter
stock.quantbylot_idif the client tracks expiry dates and needs near-expiry low-stock alerts separately from general stockouts. - Supplier link: join against
product.supplierinfoto include the preferred supplier and lead time in the alert message — so the ops team can open a PO immediately, not just react. - Escalation on repeated alerts: if the same SKU appears in alerts three runs in a row without improvement, route to a different Slack channel or escalate to the warehouse manager.
- Write back to Odoo: instead of alerting, auto-create a
stock.warehouse.orderpointreplenishment for products with a configuredroute_id— moving from notification to action.
The uninvoiced sales order alert covers the same JSON-RPC authentication pattern and consolidation approach in a different operational context — worth reading if this is your first n8n/Odoo integration.
At Trobz, we build and maintain n8n automation stacks on top of Odoo for operations teams across Southeast Asia — if you’re setting this up and hit an edge case specific to your warehouse configuration, reach out and we can share the adjusted workflow JSON.