Odoo supports UHF RFID natively as of version 18, and the documentation is explicit about what that support covers: a Zebra RFD40 reader paired to a Zebra TCX-series mobile computer or an Android smartphone. Three constraints define the boundary of that path — SGTIN-96 is the only supported encoding scheme, serial numbers inside the EPC must be numeric only, and products must carry GS1-registered GTIN-14 barcodes.
Most warehouses asking for RFID fail at least one of those. The reader is a Chainway C72 bought two years before anyone said the word "Odoo." The serial numbers are alphanumeric because they came out of a legacy ERP. The products were never GS1-registered because they are sold through channels that never required it. When any of those is true, the native path is closed and the tags have to reach Odoo another way.
That other way is the subject of this guide: read the tags outside Odoo, resolve and deduplicate the EPCs before they cross the network, and write the results in through the API as batched inventory adjustments.
What the vendor docs don't tell you
Odoo's native RFID documentation states its hardware and encoding requirements plainly, but it does not tell you what to do when you fail them — and it does not mention request volume at all. Odoo's JSON-RPC API assumes traffic matching human scan speed, a few requests per second at most. UHF bulk reads blow past that assumption by an order of magnitude before any deduplication happens.
There is a second, quieter constraint in the native path worth knowing before you plan around it: lot-tracked products are counted as a single unit with quantity 1, not as individually tagged items. If the operation depends on per-item serialization, lot tracking will not deliver it.
What the docs don't tell you
A single UHF antenna sweep over a shelf of tagged items generates repeat read events for every tag still inside the field, not one event per tag. Sending each of those raw events to Odoo's JSON-RPC endpoint as an individual stock.quant write means the database absorbs write volume that has no relationship to actual inventory change — the same item gets "counted" dozens of times in the same second, and under load this is enough to visibly degrade response time for every other user on the same Odoo instance.
Architecture
The integration needs three stages between the antenna and the Odoo database: a raw read stream, a dedup buffer with a session boundary, and a batched write to Odoo.
Android UHF reader (RFIDWithUHFUART or equivalent)
│ raw tag reads, includes repeats
▼
Dedup buffer (in-memory Set<EPC>, TTL per session)
│ one record per unique EPC, batched
▼
JSON-RPC batch call → stock.quant / inventory adjustment
│
▼
Odoo (server-side reconciliation against expected stock)
Android: dedup buffer and JSON-RPC client
The dedup logic mirrors what a stocktake app needs regardless of backend — the Odoo-specific part is the batched RPC call at the end of the session, not per-tag.
data class RfidQuantUpdate(val epc: String, val productBarcode: String, val count: Int)
class OdooJsonRpcClient(private val baseUrl: String, private val db: String, private val uid: Int, private val password: String) {
private val client = OkHttpClient()
private val mediaType = "application/json".toMediaType()
fun batchUpdateQuants(updates: List<RfidQuantUpdate>): Boolean {
val calls = updates.map { u ->
mapOf(
"jsonrpc" to "2.0",
"method" to "call",
"params" to mapOf(
"service" to "object",
"method" to "execute_kw",
"args" to listOf(
db, uid, password,
"stock.quant", "write",
listOf(listOf(mapOf("barcode" to u.productBarcode)), mapOf("quantity" to u.count))
)
)
)
}
val body = Gson().toJson(mapOf("batch" to calls)).toRequestBody(mediaType)
val request = Request.Builder().url("$baseUrl/jsonrpc").post(body).build()
client.newCall(request).execute().use { response ->
return response.isSuccessful
}
}
}This sketch batches at the HTTP layer conceptually; Odoo's JSON-RPC endpoint itself expects one call per request, so in practice the batching happens by accumulating updates client-side during the scan session and issuing sequential calls only once the session ends — not by streaming a request per tag read during the sweep itself.
class ScanSessionBuffer {
private val seen = mutableMapOf<String, Int>()
fun onRead(epc: String) {
seen[epc] = (seen[epc] ?: 0) + 1
}
fun finalizeSession(epcToBarcodeMap: Map<String, String>): List<RfidQuantUpdate> {
return seen.mapNotNull { (epc, count) ->
epcToBarcodeMap[epc]?.let { barcode -> RfidQuantUpdate(epc, barcode, count) }
}
}
}The epcToBarcodeMap lookup is deliberate — Odoo's inventory model is keyed on product barcode, not EPC. Somewhere upstream, EPC needs to be resolved to a known product barcode, either through an encoding scheme where the EPC embeds a GTIN, or through a lookup table maintained separately.
Odoo side: receiving the batch
A thin custom endpoint or a scripted execute_kw call against stock.quant handles the write side. For anything beyond a simple quantity overwrite — like reconciling counted quantity against expected quantity — a small server action in Python is more maintainable than pushing that logic into the mobile client.
# Odoo server action / scheduled reconciliation, sketch only
def reconcile_rfid_count(env, location_id, counted_quantities):
"""counted_quantities: dict of {barcode: count}"""
Quant = env['stock.quant']
for barcode, count in counted_quantities.items():
product = env['product.product'].search([('barcode', '=', barcode)], limit=1)
if not product:
continue
quant = Quant.search([
('product_id', '=', product.id),
('location_id', '=', location_id),
], limit=1)
if quant:
quant.inventory_quantity = count
else:
Quant.create({
'product_id': product.id,
'location_id': location_id,
'inventory_quantity': count,
})
env['stock.quant']._apply_inventory()Setting inventory_quantity and calling _apply_inventory() is Odoo's standard mechanism for a counted-quantity reconciliation, distinct from directly writing quantity, which bypasses the inventory-adjustment audit trail.
Production considerations
- Batch at session boundaries, not per-read. The dedup buffer should hold the full session's unique EPCs before a single write pass goes to Odoo, not stream individual writes as tags are read.
- Resolve EPC to product barcode before the data reaches Odoo. Odoo's native RFID path understands EPCs only under SGTIN-96, where the GTIN is embedded in the encoding; on the API path there is no such interpretation, so pushing a raw EPC into a barcode field will silently fail to match existing products.
- Use
inventory_quantity+_apply_inventory()for counted reconciliation rather than directquantitywrites, to preserve Odoo's inventory-adjustment audit trail. - Rate-limit the mobile client's retry behavior against the JSON-RPC endpoint — a flaky warehouse Wi-Fi connection retried aggressively can produce the same load problem the dedup buffer was built to avoid.
Common errors
| Error | Cause | Fix |
|---|---|---|
| Odoo instance slows down during scan sessions | Per-read JSON-RPC calls instead of session-batched writes | Buffer reads client-side and issue writes only at session end |
| Scanned items don't match any product in Odoo | EPC pushed directly where a barcode was expected | Resolve EPC → product barcode via an embedded GTIN scheme or lookup table before the write |
| Inventory counts overwrite without audit trail | Direct quantity field write instead of inventory_quantity + _apply_inventory() | Use the standard inventory-adjustment mechanism, not a raw quantity write |
| Duplicate stock.quant records for the same product/location | Missing search-before-create logic in the reconciliation action | Search for an existing quant at that product/location before creating a new one |