Multi-Device Hardware API

One REST API for RFID readers, decoders and other networked hardware — no device registration, just name the device in your request.

v1
API version
2 devices
ur4 · jmq-decoder
Stateless
No device registration
✅ Verified
Live UR4 · 2026-08-19

The API is built around three ideas:

  • One shape for every device. The ur4 RFID reader and the jmq-decoder deactivator are called exactly the same way — you change the device type in the URL, nothing else. New device types appear without the endpoints changing.
  • Discovery built in. GET /api/v1/devices returns the full catalog: every supported type, what it can do, which parameters each action takes, and the exact endpoints to call. Clients can build UIs from this alone.
  • Stateless operations. Every device call carries host + port. The server connects, executes, returns, disconnects. Operate unlimited devices with zero pre-configuration.
Verification status: the entire v1 surface was tested end-to-end against a live UR4 reader at 192.168.1.213:8888 on 2026-08-19 — catalog, test/info, all three actions (a 4-second TID scan read 12 unique tags / 151 reads), all data endpoints, and every error path. UR4 example responses are real captured output.
JMQ decoder status: the decoder hardware is currently not connected, so its commands answer with a connection error until it is plugged in. Its catalog, parameter validation, and error responses are verified and working; the success-response shapes shown below are the documented ones.

Endpoint Map

AreaEndpointsPurpose
CatalogGET /v1/devices · GET /v1/devices/{type}Discover device types, capabilities, actions
OperationsPOST /v1/devices/{type}/test · /info · /actions/{action}Talk to physical hardware (any type)
RFID DataGET /v1/rfid/stats · /tags · /scans · /sessions · /tid-logsQuery persisted scan history (database only)
Legacy/api/test, /api/scan, … (unversioned)Deprecated — kept for backward compatibility

Key Concepts

TermDescription
Device typeShort name for a kind of hardware, e.g. ur4. It goes in the URL.
CapabilitySomething a device can do, e.g. tid_reading, power_control
ActionA command you send to a device (scan, set-power, kill, …), each with its own parameters
EPCElectronic Product Code — 24-hex-char unique RFID tag identifier
TIDTag Identifier — factory-programmed chip serial number (24 hex chars)
RSSISignal strength in dBm (e.g. -6.7); closer to 0 = stronger
SessionOne timed scan window; all reads share a UUID session_id

Getting Started

Three things and you are making calls: the base URL, your API key, and a first request.

1 · Base URL

Everything lives under this address — already filled in with the server you are reading these docs from:

base url
http://localhost:8090/api/v1

2 · Your API key

Every request must carry a key. The one below is live on this server and is pre-filled into every example on this page — details in Authentication.

header
X-Api-Key: <your-api-key>

3 · Your first request

Ask the server which devices it supports. If this returns a list, you are connected and authenticated:

curl
curl http://localhost:8090/api/v1/devices -H "Accept: application/json" \
  -H "X-Api-Key: <your-api-key>"

4 · Read some tags

Point at a reader on your network and scan for 5 seconds. Every read is stored automatically, so you can query the history afterwards:

curl
curl -X POST http://localhost:8090/api/v1/devices/ur4/actions/scan \
  -H "Content-Type: application/json" -H "Accept: application/json" \
  -H "X-Api-Key: <your-api-key>" \
  -d '{"host": "192.168.1.213", "port": 8888, "duration": 5, "with_tid": true}'
host and port are the reader's own address on your network — you never register a device anywhere, you just name it in the request.

Conventions

Base URL & Headers

Base URL: http://localhost:8090/api/v1 — send these headers on every request:

headers
Content-Type: application/json
Accept: application/json
X-Api-Key: <your-api-key>
Every API request needs a key — see Authentication. The X-Api-Key header is omitted from the examples below for brevity, but it is always required.
Accept: application/json matters: without it, validation failures may come back as a redirect instead of a 422 JSON body.

Rate limiting

Every API route is throttled to 120 requests per minute per caller. Responses carry X-RateLimit-Limit and X-RateLimit-Remaining; exceeding the limit returns 429.

Response Envelope

json
{ "success": true,  "data": { ... } }
{ "success": false, "message": "Human-readable description" }

Common Device Fields

Every device operation (test, info, actions/*) requires the target's address in the JSON body:

FieldTypeRequiredDescription
hoststring (IPv4)YesIP address of the device
portinteger (1–65535)YesTCP port — UR4 default is 8888

🔐 Authentication

Every endpoint requires an API key. Send it in the X-Api-Key header (or as Authorization: Bearer <key>).

Each key carries a permission level, so a read-only key can never deactivate a tag. Keys are issued by your administrator.

Whatever you type is remembered in this browser only, and is sent nowhere except to this API when you press Verify key. Clear it to fall back to the current admin key.
GET /api/v1/devices

    

Sending the key

curl
# preferred: dedicated header
curl "http://localhost:8090/api/v1/devices" \
  -H "Accept: application/json" \
  -H "X-Api-Key: rfid_operator_xxxxxxxxxxxxxxxx"

# also accepted: bearer token
curl "http://localhost:8090/api/v1/devices" \
  -H "Authorization: Bearer rfid_operator_xxxxxxxxxxxxxxxx"

Scopes

Each key holds one or more scopes. Every action states the scope it needs, and the catalog publishes it — so you can tell in advance what a key is allowed to do.

ScopeGrantsRisk
data.readThe device catalog and all /v1/rfid/* history queriesSafe — database only
device.operatetest, info, and the everyday actions: scan, read, stop, set-power, read-memoryTouches hardware
tag.writewrite-memory, write-epc, lockModifies tags
tag.killkill — permanent tag deactivationIrreversible
*Everything aboveFull access

Permission levels

Three levels are available. Ask your administrator for the narrowest one that covers what you need.

LevelScopes it holdsTypical use
Read-onlydata.readDashboards, reports, inventory views
Operatordata.read · device.operateScanning stations, day-to-day operation
Admin* — everythingTag writing and permanent deactivation

Getting and changing a key

The key shown on this page is the current Admin key, so every example works as-is. In practice:

  • Need a key? Ask the administrator — they can hand you one at the level you need.
  • Key changed or leaked? The administrator can replace any key in seconds; the old one stops working immediately and this page will show the new one.
  • Keys never expire on their own — they stay valid until the administrator changes them.
Because the admin key grants everything, including the irreversible kill command, use a lower level for anything that only reads or scans.

Rejection responses

401 — missing or invalid key:

json · captured live
{ "success": false, "message": "API key missing. Send it in the X-Api-Key header." }
{ "success": false, "message": "Invalid API key." }

403 — valid key, insufficient scope. The response names the scope required and the ones held, so the caller knows which key to use:

json · captured live — operator key attempting kill
{
  "success": false,
  "message": "This API key is not allowed to perform this action (requires scope 'tag.kill').",
  "required_scope": "tag.kill",
  "granted_scopes": ["data.read", "device.operate"]
}
The scope check runs before parameter validation, so an unauthorised caller never learns anything from validation messages. Legacy unversioned routes (/api/scan, /api/tags, …) require the same keys — they are not a way around authentication.

Operational notes

  • Serve the API over HTTPS — a static key in a header is only as safe as the transport.
  • Set APP_DEBUG=false in production so errors do not leak stack traces.
  • Keep the JMQ bridge bound to localhost; it has no authentication of its own by default.
  • This page and the home page stay public; only /api/* is protected.

Device Catalog

Discovery endpoints. The catalog tells clients everything: which device types exist, what each can do, which parameters each action takes, and the exact URLs to call.

A dashboard can render its entire device UI from this response — no hardcoding.

List Device Types

GET /api/v1/devices ✓ verified live

Example

curl
curl http://localhost:8090/api/v1/devices -H "Accept: application/json" \
  -H "X-Api-Key: <your-api-key>"
GET /api/v1/devices

    

Response 200

json · captured live
{
  "success": true,
  "data": {
    "count": 2,
    "devices": [
      {
        "type": "ur4",
        "name": "Chainway UR4 UHF RFID Reader",
        "category": "rfid-reader",
        "transport": "tcp",
        "capabilities": ["inventory", "tid_reading", "user_memory", "power_control", "multi_antenna"],
        "actions": {
          "scan": {
            "description": "Run a blocking inventory scan and persist every tag read (tags, scans, session, TID logs).",
            "params": { "duration": "integer|min:1|max:60", "with_tid": "boolean" }
          },
          "stop": {
            "description": "Send a stop-inventory command (e.g. if the reader was left scanning).",
            "params": []
          },
          "set-power": {
            "description": "Set RF transmit power in dBm. Higher power = longer read range.",
            "params": { "power": "required|integer|min:0|max:33" }
          }
        },
        "endpoints": {
          "test": "/api/v1/devices/ur4/test",
          "info": "/api/v1/devices/ur4/info",
          "action": "/api/v1/devices/ur4/actions/{action}"
        }
      },
      {
        "type": "jmq-decoder",
        "name": "JMQ400300 UHF Decoder / Deactivator",
        "category": "rfid-decoder",
        "transport": "http-bridge",
        "capabilities": ["tid_reading", "user_memory", "power_control", "tag_write", "tag_kill", "tag_lock"],
        "actions": { "read": {…}, "set-power": {…}, "read-memory": {…}, "write-memory": {…}, "write-epc": {…}, "kill": {…}, "lock": {…} },
        "endpoints": {
          "test": "/api/v1/devices/jmq-decoder/test",
          "info": "/api/v1/devices/jmq-decoder/info",
          "action": "/api/v1/devices/jmq-decoder/actions/{action}"
        }
      }
    ]
  }
}

Describe One Device Type

GET /api/v1/devices/{type} ✓ verified live

Same object as one entry of the list above. Unknown types return 404 with the available types:

json · captured live — GET /v1/devices/printer
{
  "success": false,
  "message": "Unknown device type 'printer'. Available types: ur4",
  "available_types": ["ur4"]
}

Device Operations

Generic hardware endpoints — identical for every device type. Replace {type} with any slug from the catalog (ur4 today).

All three require host + port in the body. The server connects, executes, and disconnects — nothing is stored about the device itself.

Test Connection

POST /api/v1/devices/{type}/test ✓ verified live

Verify connectivity and return basic device info.

Example

curl
curl -X POST http://localhost:8090/api/v1/devices/ur4/test \
  -H "Content-Type: application/json" -H "Accept: application/json" \
  -H "X-Api-Key: <your-api-key>" \
  -d '{"host": "192.168.1.213", "port": 8888}'

Response 200

json · captured live
{
  "success": true,
  "data": {
    "type": "ur4",
    "connected": true,
    "host": "192.168.1.213",
    "port": 8888,
    "power": null
  },
  "message": "Connection successful"
}

Response 500 — device unreachable

json
{ "success": false, "message": "Failed to connect to device" }

Get Device Info

POST /api/v1/devices/{type}/info ✓ verified live

Live device information. Body identical to test; response identical minus the message.

json · captured live
{
  "success": true,
  "data": { "type": "ur4", "connected": true, "host": "192.168.1.213", "port": 8888, "power": null }
}
power may be null when the reader doesn't answer the GET_POWER query in time; connectivity is still confirmed by connected: true.

Execute an Action

POST /api/v1/devices/{type}/actions/{action} ✓ verified live

The heart of the API. {action} is any action listed for that device in the catalog, and your parameters are checked against the rules shown there. Responses always echo which device and action ran:

json — response shape
{
  "success": true,
  "data": {
    "device": { "type": "ur4", "host": "...", "port": 8888 },
    "action": "scan",
    // ...action-specific payload
  }
}

Unknown action → 404 with the valid list

json · captured live — POST .../ur4/actions/print
{
  "success": false,
  "message": "Unknown action 'print' for device type 'ur4'.",
  "available_actions": ["scan", "stop", "set-power"]
}

UR4 Actions

Device type ur4 — a Chainway UR4 UHF RFID reader, reached directly over your network.

What it supports: bulk tag inventory, reading TIDs and user memory, power control, and up to 4 antennas.

scan

POST /api/v1/devices/ur4/actions/scan ✓ verified live · 12 tags

Run a blocking inventory scan for duration seconds, then return every unique tag read. Everything is persisted automatically: tags upserted, raw scans recorded, a session created, TID logs written when with_tid is enabled.

Body

FieldTypeRequiredDefaultConstraints
host / portstandard device fields
durationintegerNo31–60 seconds
with_tidbooleanNofalsealso read each tag's factory TID
The HTTP request stays open for the full duration — set your client timeout above duration + ~3s.

Example

curl
curl -X POST http://localhost:8090/api/v1/devices/ur4/actions/scan \
  -H "Content-Type: application/json" -H "Accept: application/json" \
  -H "X-Api-Key: <your-api-key>" \
  -d '{"host": "192.168.1.213", "port": 8888, "duration": 4, "with_tid": true}'

Response 200

json · captured live — 12 tags read
{
  "success": true,
  "data": {
    "device": { "type": "ur4", "host": "192.168.1.213", "port": 8888 },
    "action": "scan",
    "session_id": "40abaeed-8d5b-45c1-a624-50e3af47922f",
    "unique_tags": 12,
    "total_reads": 12,
    "tags": [
      {
        "epc": "E28011606000021268DC33A2",
        "tid": "E2801160200063A21B860A4D",
        "rssi": -6.32,
        "antenna": 1,
        "read_count": 14,
        "user_data": null,
        "timestamp": "2026-08-19T09:07:27.977755Z",
        "device_type": "ur4"
      }
      // ... 11 more tags
    ]
  }
}

Tag object fields

FieldTypeDescription
epcstring24-hex-char Electronic Product Code
tidstring | nullFactory chip serial (only when with_tid: true)
rssifloatSignal strength in dBm
antennaintegerAntenna port (1–4)
read_countintegerTimes this tag was read during the scan
user_datastring | nullUser memory bank content, if read
timestampstring (ISO 8601)Last read time

stop

POST /api/v1/devices/ur4/actions/stop ✓ verified live

Send a stop-inventory command — useful if a reader was left in inventory mode. No extra parameters.

curl
curl -X POST http://localhost:8090/api/v1/devices/ur4/actions/stop \
  -H "Content-Type: application/json" -H "Accept: application/json" \
  -H "X-Api-Key: <your-api-key>" \
  -d '{"host": "192.168.1.213", "port": 8888}'
json · captured live
{
  "success": true,
  "data": {
    "device": { "type": "ur4", "host": "192.168.1.213", "port": 8888 },
    "action": "stop",
    "stopped": true
  }
}

set-power

POST /api/v1/devices/ur4/actions/set-power ✓ verified live

Set RF transmit power. Higher power = longer read range (and more reflections).

Body

FieldTypeRequiredConstraints
host / portstandard device fields
powerintegerYes0–33 (dBm)
curl
curl -X POST http://localhost:8090/api/v1/devices/ur4/actions/set-power \
  -H "Content-Type: application/json" -H "Accept: application/json" \
  -H "X-Api-Key: <your-api-key>" \
  -d '{"host": "192.168.1.213", "port": 8888, "power": 26}'
json · captured live
{
  "success": true,
  "data": {
    "device": { "type": "ur4", "host": "192.168.1.213", "port": 8888 },
    "action": "set-power",
    "power": 26
  }
}

Response 422 — out of range

json · captured live (power: 99)
{
  "message": "The power field must not be greater than 33.",
  "errors": { "power": ["The power field must not be greater than 33."] }
}

Decoder Actions (JMQ400300)

Device type jmq-decoder — a JMQ400300 UHF decoder/deactivator. Beyond reading tags it can write to them, change their EPC, lock them, and permanently deactivate them.

What it supports: reading TIDs and user memory, power control, tag write, tag lock, tag kill.

How you address it

For this device, host + port address the decoder service — normally 127.0.0.1 port 9100 — not the decoder hardware itself. The hardware's own address and radio settings are configured once by the administrator, so you always call the same host and port.
Hardware currently offline: the decoder is not connected right now, so its commands answer with a connection error until it is plugged in. Parameter validation and error responses are verified and working; the success bodies below are the documented shapes, marked accordingly.

Connection errors tell you which part failed

The message separates "the decoder service is down" from "the service is up but the hardware is unreachable":

json · captured live
// bridge running, decoder hardware disconnected:
{ "success": false, "message": "Failed to connect to JMQ decoder (bridge up at http://127.0.0.1:9100, reader 192.168.0.250:8080 unreachable)" }

// bridge itself not running:
{ "success": false, "message": "Failed to connect to JMQ decoder (bridge unreachable at http://127.0.0.1:9199)" }

Gen2 memory banks (used by memory/lock actions)

BankNameContents
0ReservedKill password (words 0–1) + access password (words 2–3)
1EPCThe tag's EPC
2TIDFactory chip serial (read-only)
3USERFree user memory

Passwords are 8 hex digits (4 bytes). On write/kill/lock/read-memory, epc is optional — omitted, the operation targets the single tag in the field; given, a Select filter targets that exact tag.

read

POST /api/v1/devices/jmq-decoder/actions/read

Poll the reader for duration seconds, deduplicating by TID (the bridge embeds each tag's TID during inventory, so tags sharing an EPC are still distinguished). Results are persisted exactly like UR4 scans — tags, scans, a session, and TID logs — keyed by the bridge host:port, and queryable via the RFID Data API.

Body

FieldTypeRequiredDefaultConstraints
host / portbridge address, e.g. 127.0.0.1 : 9100
durationintegerNo31–60 seconds (request blocks this long)

Example

curl
curl -X POST http://localhost:8090/api/v1/devices/jmq-decoder/actions/read \
  -H "Content-Type: application/json" -H "Accept: application/json" \
  -H "X-Api-Key: <your-api-key>" \
  -d '{"host": "127.0.0.1", "port": 9100, "duration": 5}'

Response 200

json — shape (per bridge API)
{
  "success": true,
  "data": {
    "device": { "type": "jmq-decoder", "host": "127.0.0.1", "port": 9100 },
    "action": "read",
    "session_id": "<uuid>",
    "unique_tags": 2,
    "total_reads": 2,
    "tags": [
      {
        "epc": "E280116060000212...",
        "tid": "E280116020006392...",
        "rssi": -41.0,
        "antenna": 1,
        "read_count": 6,
        "device_type": "jmq400300"
      }
    ]
  }
}

set-power

POST /api/v1/devices/jmq-decoder/actions/set-power

Set RF transmit power in dBm (power: required integer 0–33). You always send plain dBm; any unit conversion happens for you.

curl
curl -X POST http://localhost:8090/api/v1/devices/jmq-decoder/actions/set-power \
  -H "Content-Type: application/json" -H "Accept: application/json" \
  -H "X-Api-Key: <your-api-key>" \
  -d '{"host": "127.0.0.1", "port": 9100, "power": 25}'

read-memory / write-memory

POST /api/v1/devices/jmq-decoder/actions/read-memory
FieldTypeRequiredConstraints
epchex stringNoomit → single tag in field
bankintegerYes0–3 (see banks table)
word_addressintegerNo≥ 0, default 0
word_countintegerYes1–64 (words = 2 bytes)
access_passwordhex stringNo8 hex digits, default 00000000

Returns { "bank": 2, "data_hex": "E28011602000..." }.

POST /api/v1/devices/jmq-decoder/actions/write-memory

Same fields, but data_hex (required hex string) replaces word_count. Returns { "written": true, "bank": …, "bytes": … }.

Initialising passwords for kill/lock: a fresh tag has kill and access passwords of 00000000. Write the Reserved bank (kill pw = words 0–1, access pw = words 2–3) first:
curl
curl -X POST http://localhost:8090/api/v1/devices/jmq-decoder/actions/write-memory \
  -H "Content-Type: application/json" \
  -d '{"host":"127.0.0.1","port":9100,"epc":"<epc>","bank":0,"word_address":0,"data_hex":"DEADBEEF12345678","access_password":"00000000"}'
# DEADBEEF = new kill password · 12345678 = new access password

write-epc

POST /api/v1/devices/jmq-decoder/actions/write-epc

Overwrite a tag's EPC.

FieldTypeRequiredConstraints
new_epc_hexhex stringYesthe new EPC
epchex stringNocurrent EPC; omit → single tag in field
access_passwordhex stringNo8 hex digits

Returns { "written": true, "new_epc": "..." }.

kill (deactivate)

POST /api/v1/devices/jmq-decoder/actions/kill

Permanently deactivates a tag — this is irreversible. Requires a non-zero kill_password previously written to the Reserved bank (see write-memory).

FieldTypeRequiredConstraints
kill_passwordhex stringYes8 hex digits, non-zero
epchex stringNoomit → single tag in field
curl
curl -X POST http://localhost:8090/api/v1/devices/jmq-decoder/actions/kill \
  -H "Content-Type: application/json" -H "Accept: application/json" \
  -H "X-Api-Key: <your-api-key>" \
  -d '{"host": "127.0.0.1", "port": 9100, "epc": "<epc>", "kill_password": "DEADBEEF"}'

Returns { "killed": true, "killed_epc": "..." }. SDK errors (e.g. kill password not initialised) come back verbatim in message with status 500.

lock

POST /api/v1/devices/jmq-decoder/actions/lock

Lock or unlock a tag memory region. Requires a non-zero access password.

FieldTypeRequiredAllowed values
lock_objectstringYeskill_password · access_password · epc · tid · user
lock_typestringYeslock · unlock · perm_lock · perm_unlock
access_passwordhex stringYes8 hex digits, non-zero
epchex stringNoomit → single tag in field

Returns { "locked": true, "lock_object": "...", "lock_type": "..." }. perm_lock is irreversible.

RFID Data API

Persisted scan history — database queries only. These endpoints never contact hardware and respond instantly.

Data is keyed by host:port, so history stays separated per device. Both UR4 scan and JMQ decoder read persist here (for the decoder, the key is the bridge address).

Device Statistics

GET /api/v1/rfid/stats?host={host}&port={port} ✓ verified live

Aggregate counters for one reader. Both query parameters are required.

curl
curl "http://localhost:8090/api/v1/rfid/stats?host=192.168.1.213&port=8888" \
  -H "Accept: application/json" \
  -H "X-Api-Key: <your-api-key>"
json · captured live
{
  "success": true,
  "data": {
    "host": "192.168.1.213",
    "port": "8888",
    "total_scans": 35,
    "unique_tags": 12,
    "scans_today": 35,
    "sessions_today": 3
  }
}

List Tags

GET /api/v1/rfid/tags ✓ verified live

All known tags, most recently seen first. Paginated.

ParamTypeDefaultDescription
with_tidboolean—Only tags that have a TID recorded
recent_minutesinteger—Only tags seen within the last N minutes
searchstring—Partial match on epc, tid, or product_name
per_page / pageinteger50 / 1Pagination
curl
curl "http://localhost:8090/api/v1/rfid/tags?with_tid=1&search=DC3D&per_page=25" \
  -H "Accept: application/json" \
  -H "X-Api-Key: <your-api-key>"
json · captured live
{
  "success": true,
  "data": {
    "current_page": 1,
    "data": [
      {
        "id": 8,
        "epc": "E28011606000021268DC3D62",
        "tid": "E2801160200075621B870A4D",
        "product_name": null,
        "sku": null,
        "first_seen_at": "2026-08-19T08:32:29.000000Z",
        "last_seen_at": "2026-08-19T09:07:31.000000Z",
        "total_read_count": 28
      }
    ],
    "per_page": 50,
    "total": 12
  }
}

Get Tag by EPC

GET /api/v1/rfid/tags/{epc} ✓ verified live

One tag (exact 24-hex-char EPC) with its 100 most recent scan records. Unknown EPC → 404.

curl
curl "http://localhost:8090/api/v1/rfid/tags/E28011606000021268DC3392" \
  -H "Accept: application/json" \
  -H "X-Api-Key: <your-api-key>"
json · captured live
{
  "success": true,
  "data": {
    "tag": {
      "epc": "E28011606000021268DC3392",
      "tid": "E2801160200063921B860A4D",
      "first_seen_at": "2026-08-19T08:32:29.000000Z",
      "last_seen_at": "2026-08-19T09:07:31.000000Z",
      "total_read_count": 28
    },
    "recent_scans": [
      {
        "host": "192.168.1.213",
        "port": 8888,
        "rssi": -6.7,
        "antenna": 1,
        "session_id": "f8d6b214-aec3-4c03-9d7e-683273ad9eb3",
        "scanned_at": "2026-08-19T08:32:29.000000Z"
      }
    ]
  }
}

List Scans

GET /api/v1/rfid/scans ✓ verified live

Raw scan records (one row per tag read per session), newest first. Each row embeds its related tag.

ParamTypeDescription
host / portstring / integerFilter by reader
session_idstring (UUID)Filter by scan session
from / todatetimeTime range, e.g. 2026-08-19 00:00:00
per_page / pageintegerPagination (default 50)
curl
curl "http://localhost:8090/api/v1/rfid/scans?host=192.168.1.213&session_id=40abaeed-8d5b-45c1-a624-50e3af47922f" \
  -H "Accept: application/json" \
  -H "X-Api-Key: <your-api-key>"
json · captured live (shape)
{
  "success": true,
  "data": {
    "current_page": 1,
    "data": [
      {
        "host": "192.168.1.213",
        "port": 8888,
        "epc": "E28011606000021268DC3392",
        "tid": "E2801160200063921B860A4D",
        "rssi": -6.7,
        "antenna": 1,
        "session_id": "40abaeed-8d5b-45c1-a624-50e3af47922f",
        "scanned_at": "2026-08-19T09:07:31.000000Z",
        "tag": { /* related RFIDTag */ }
      }
    ],
    "per_page": 50,
    "total": 12
  }
}

List Sessions

GET /api/v1/rfid/sessions ✓ verified live

Scan sessions, newest first.

ParamTypeDescription
host / portstring / integerFilter by reader
statusstringrunning | completed | failed
per_page / pageintegerPagination (default 20)
json · captured live
{
  "success": true,
  "data": {
    "current_page": 1,
    "data": [
      {
        "session_id": "40abaeed-8d5b-45c1-a624-50e3af47922f",
        "host": "192.168.1.213",
        "port": 8888,
        "started_at": "2026-08-19T09:07:27.000000Z",
        "ended_at": "2026-08-19T09:07:31.000000Z",
        "total_tags": 12,
        "total_reads": 151,
        "status": "completed",
        "metadata": { "duration": 4, "with_tid": true }
      }
    ],
    "per_page": 20,
    "total": 3
  }
}

Get Session

GET /api/v1/rfid/sessions/{sessionId} ✓ verified live

One session (the UUID returned by the scan action) with all of its scan records. Unknown ID → 404.

curl
curl "http://localhost:8090/api/v1/rfid/sessions/40abaeed-8d5b-45c1-a624-50e3af47922f" \
  -H "Accept: application/json" \
  -H "X-Api-Key: <your-api-key>"
json — shape
{
  "success": true,
  "data": {
    "session": { /* session object, as in List Sessions */ },
    "scans":   [ /* every scan row in this session */ ]
  }
}

List TID Logs

GET /api/v1/rfid/tid-logs ✓ verified live

TID (factory chip serial) sighting log — one row per unique TID per reader, with first/last-seen timestamps and cumulative read count. Written whenever a scan runs with with_tid: true.

ParamTypeDescription
searchstringPartial match on tid, epc, or location
host / portstring / integerFilter by reader
per_page / pageintegerPagination (default 50)
json · captured live
{
  "success": true,
  "data": {
    "current_page": 1,
    "data": [
      {
        "tid": "E2801160200063921B860A4D",
        "epc": "E28011606000021268DC3392",
        "host": "192.168.1.213",
        "port": 8888,
        "session_id": "f8d6b214-aec3-4c03-9d7e-683273ad9eb3",
        "rssi": -6.7,
        "antenna": 1,
        "location": null,
        "first_seen_at": "2026-08-19T08:32:29.000000Z",
        "last_seen_at": "2026-08-19T09:07:31.000000Z",
        "read_count": 2
      }
    ],
    "per_page": 50,
    "total": 12
  }
}

Pagination

List endpoints return a paginated object inside data:

FieldDescription
current_pageCurrent page number
dataArray of records for this page
per_pagePage size
totalTotal records across all pages
last_pageNumber of the final page
next_page_url / prev_page_urlReady-made navigation URLs (or null)

Navigate with ?page=N; change page size with ?per_page=N.

Errors

HTTP Status Codes

CodeMeaningWhen
200OKRequest handled successfully
401UnauthorizedAPI key missing or invalid — see Authentication
403ForbiddenValid key, but it lacks the scope this operation requires
404Not FoundUnknown device type, unknown action, unknown EPC / session ID
429Too Many RequestsRate limit exceeded (120 requests/minute)
422Unprocessable EntityValidation failed (bad IP, missing field, out-of-range value)
500Server ErrorDevice unreachable, TCP failure, or unexpected exception

404 — Unknown device type / action

Discovery-friendly: the response tells you what is valid.

json · captured live
// GET /v1/devices/printer
{
  "success": false,
  "message": "Unknown device type 'printer'. Available types: ur4",
  "available_types": ["ur4"]
}

// POST /v1/devices/ur4/actions/print
{
  "success": false,
  "message": "Unknown action 'print' for device type 'ur4'.",
  "available_actions": ["scan", "stop", "set-power"]
}

422 — Validation error

Device fields (host, port) and the action's own parameters are validated together.

json · captured live
{
  "message": "The power field must not be greater than 33.",
  "errors": { "power": ["The power field must not be greater than 33."] }
}

500 — Device error

json · captured live
{ "success": false, "message": "Failed to connect to device" }

404 — Unknown EPC / session

json
{ "message": "No query results for model [App\\Models\\RFIDTag]." }
With APP_DEBUG=true (local development), 404/500 responses include a full stack trace. Set APP_DEBUG=false in production.

Troubleshooting

"Failed to connect to device"

  1. Verify raw TCP reachability from the API server:
    bash
    timeout 5 bash -c 'echo > /dev/tcp/192.168.1.213/8888' && echo OK || echo UNREACHABLE
  2. Confirm the device's IP and port (UR4 default TCP port is 8888).
  3. Check the device is in server/TCP mode and not held by another client — the UR4 typically serves one TCP client at a time.
  4. Check firewalls/VLANs between the API server and the device.

Scan returns 0 tags

  • Move tags within antenna range, or raise power via the set-power action (max 33).
  • Increase duration.
  • Confirm the antenna port in use is connected.

Scan request times out on the client side

The scan action blocks for the full duration. Set your HTTP client timeout to at least duration + 3 seconds.

power is null in test/info

The reader accepted the connection but didn't answer the GET_POWER query in time. Connectivity is fine (connected: true); it does not affect scanning.

Validation errors come back as HTML/redirect

Add the Accept: application/json header to the request.

Legacy API (deprecated)

POST /api/test · /info · /power · /scan · /stop deprecated
GET /api/stats · /tags · /scans · /sessions · /tid-logs deprecated

The original unversioned UR4-only endpoints still work unchanged (verified live), but new integrations should use /api/v1. Mapping:

Legacyv1 replacement
POST /api/testPOST /api/v1/devices/ur4/test
POST /api/infoPOST /api/v1/devices/ur4/info
POST /api/scanPOST /api/v1/devices/ur4/actions/scan
POST /api/stopPOST /api/v1/devices/ur4/actions/stop
POST /api/powerPOST /api/v1/devices/ur4/actions/set-power
GET /api/stats, /tags, /scans, /sessions, /tid-logsSame paths under GET /api/v1/rfid/…

Multi-Device Hardware API v1 · verified against a live UR4 reader on 2026-08-19.