ASQUARETE

Integrating UHF RFID Scan Data into Odoo

Generic UHF handheld · Odoo · Updated Jul 22, 2026 · Platform Integration

Odoo 18 and 19 support UHF RFID natively, but only with a Zebra RFD40 paired to a TCX-series device or Android phone, only SGTIN-96 encoding with numeric-only serials, and only GS1-registered GTIN-14 barcodes. Outside those three constraints — Chainway hardware, alphanumeric serials, non-GS1 products — RFID reaches Odoo through a dedup buffer and batched JSON-RPC against stock.quant.

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.

Device photo — Android UHF reader app connected to Odoo via JSON-RPC
Android reader → dedup buffer → Odoo JSON-RPC — the path Barcode-first tools don't cover

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 direct quantity writes, 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

ErrorCauseFix
Odoo instance slows down during scan sessionsPer-read JSON-RPC calls instead of session-batched writesBuffer reads client-side and issue writes only at session end
Scanned items don't match any product in OdooEPC pushed directly where a barcode was expectedResolve EPC → product barcode via an embedded GTIN scheme or lookup table before the write
Inventory counts overwrite without audit trailDirect 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/locationMissing search-before-create logic in the reconciliation actionSearch for an existing quant at that product/location before creating a new one

FAQ

Does Odoo support UHF RFID natively?

Yes, as of Odoo 18 and 19, but within a narrow envelope: a Zebra RFD40 reader paired to a Zebra TCX-series mobile computer or an Android smartphone, SGTIN-96 encoding with numeric-only serial numbers, and products carrying GS1-registered GTIN-14 barcodes. Alphanumeric serials and non-Zebra readers such as Chainway handhelds fall outside native support.

What if my hardware or data doesn't meet Odoo's native RFID requirements?

Read the tags outside Odoo and write the results in through the API. An Android app deduplicates EPCs on-device, resolves each EPC to an Odoo product, and posts batched JSON-RPC writes against stock.quant. This path is hardware-agnostic and imposes no encoding scheme or GS1 registration requirement.

Why not just call the Odoo API once per tag read?

A bulk UHF sweep can produce hundreds of read events per second, and most of those are repeat reads of the same physical tag. Calling JSON-RPC once per read saturates the endpoint and the database with redundant writes for data that hasn't actually changed.

What Odoo model should hold RFID-driven stock movements?

stock.quant for on-hand quantity adjustments, or stock.move / stock.picking if the read is tied to a specific inventory operation like a transfer or receipt, rather than a raw quantity correction.

Does this replace Ventor or similar barcode-first mobile apps?

No — this fills a gap those tools don't serve. Ventor and similar apps are built around discrete barcode scans matched to a single line item; bulk UHF inventory reads are a different data shape entirely, and need their own ingestion path regardless of what barcode tooling is already in place.

Asquarete · Published Jul 18, 2026 · Updated Jul 22, 2026

Related guides

Stuck on this device?

Feasibility read in 24 hours.

Get a feasibility read