Devices API¶
All device endpoints require authentication and live under the /api/devices prefix. These manage the lifecycle of e-ink devices: listing, binding, unbinding, sending commands, and reading state.
Device Model¶
{
"id": "01912345-6789-7abc-def0-123456789abc",
"hwId": "a1b2c3d4-5678-9012-abcd-ef0123456789",
"thingName": "inklet-a1b2c3d4",
"firmware": "1.2.0",
"battery": 85,
"online": true,
"lastSeenAt": "2026-01-16T08:30:00Z",
"ownerId": "01912345-0000-7abc-def0-000000000001",
"boundAt": "2026-01-15T14:00:00Z",
"claimCode": null,
"state": "{\"screen\":\"text\",\"lastCmd\":\"abc123\"}",
"stateUpdatedAt": "2026-01-16T08:25:00Z"
}
| Field | Type | Description |
|---|---|---|
id |
UUID v7 | Database primary key |
hwId |
string | Hardware UUID burned into the device at factory |
thingName |
string | AWS IoT Core Thing name (assigned during provisioning) |
firmware |
string or null | Firmware version reported by the device |
battery |
integer or null | Battery percentage (0--100) |
online |
boolean | Whether the device is currently connected to IoT Core |
lastSeenAt |
timestamp or null | Last heartbeat timestamp |
ownerId |
UUID or null | User who owns this device (null if unbound) |
boundAt |
timestamp or null | When the device was bound to the current owner |
claimCode |
string or null | 6-character pairing code (only set when device is unbound) |
state |
JSON string or null | Arbitrary device state reported via MQTT |
stateUpdatedAt |
timestamp or null | When the state was last updated |
Note
The claimCode is only present on unbound devices. Once a device is bound to a user, the claim code is cleared.
Endpoints¶
GET /api/devices¶
:material-lock: Requires authentication
List all devices bound to the authenticated user.
Request Headers:
Response: 200 OK
[
{
"id": "01912345-6789-7abc-def0-123456789abc",
"hwId": "a1b2c3d4-5678-9012-abcd-ef0123456789",
"thingName": "inklet-a1b2c3d4",
"firmware": "1.2.0",
"battery": 85,
"online": true,
"lastSeenAt": "2026-01-16T08:30:00Z",
"ownerId": "01912345-0000-7abc-def0-000000000001",
"boundAt": "2026-01-15T14:00:00Z",
"claimCode": null,
"state": null,
"stateUpdatedAt": null
}
]
Returns an empty array [] if the user has no bound devices.
GET /api/devices/{thing}¶
:material-lock: Requires authentication --- owner only
Retrieve detailed information for a specific device by its Thing name.
Path Parameters:
| Parameter | Description |
|---|---|
thing |
The device's thingName (e.g., inklet-a1b2c3d4) |
Response: 200 OK
{
"id": "01912345-6789-7abc-def0-123456789abc",
"hwId": "a1b2c3d4-5678-9012-abcd-ef0123456789",
"thingName": "inklet-a1b2c3d4",
"firmware": "1.2.0",
"battery": 85,
"online": true,
"lastSeenAt": "2026-01-16T08:30:00Z",
"ownerId": "01912345-0000-7abc-def0-000000000001",
"boundAt": "2026-01-15T14:00:00Z",
"claimCode": null,
"state": "{\"screen\":\"text\",\"lastCmd\":\"abc123\"}",
"stateUpdatedAt": "2026-01-16T08:25:00Z"
}
Errors:
| Code | Cause |
|---|---|
401 |
Missing or invalid access token |
403 |
Authenticated user is not the device owner |
404 |
Device not found |
GET /api/devices/{thing}/state¶
:material-lock: Requires authentication --- owner only
Retrieve the raw JSON state reported by the device. This returns the state field parsed as JSON rather than as a string.
Path Parameters:
| Parameter | Description |
|---|---|
thing |
The device's thingName |
Response: 200 OK
Tip
Use this endpoint when you need to read the device state as a structured JSON object. The GET /api/devices/{thing} endpoint returns the state as an escaped JSON string within the device object.
Errors:
| Code | Cause |
|---|---|
401 |
Missing or invalid access token |
403 |
Authenticated user is not the device owner |
404 |
Device not found or no state has been reported |
POST /api/devices/bind/nfc¶
:material-lock: Requires authentication
Bind a device to the authenticated user using an NFC payload. The backend verifies the HMAC signature against the factory secret before binding.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
hwId |
string | Yes | Hardware UUID read from the NFC tag |
signature |
string | Yes | First 16 hex characters of HMAC-SHA256(hwId, FACTORY_SECRET) |
Response: 200 OK
Returns the full Device object with the ownerId set to the authenticated user.
Errors:
| Code | Cause |
|---|---|
400 |
Missing fields or invalid signature |
404 |
No device found with the given hwId |
409 |
Device is already bound to another user |
POST /api/devices/bind/code¶
:material-lock: Requires authentication
Bind a device to the authenticated user using a 6-character claim code displayed on the device screen.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
code |
string | Yes | 6-character claim code (case-insensitive) |
Response: 200 OK
Returns the full Device object with the ownerId set to the authenticated user.
Errors:
| Code | Cause |
|---|---|
400 |
Missing or invalid code format |
404 |
No device found with the given claim code |
409 |
Device is already bound to another user |
410 |
Claim code has expired |
Where do claim codes come from?
When a device is unbound and powered on, it publishes a request_claim message via MQTT. The backend generates a 6-character code, stores it in the database, and sends it back via the claim_code command. The device renders it on its e-ink screen.
POST /api/devices/{thing}/unbind¶
:material-lock: Requires authentication --- owner only
Unbind a device from the authenticated user. The device is returned to an unbound state and will request a new claim code.
Path Parameters:
| Parameter | Description |
|---|---|
thing |
The device's thingName |
Response: 200 OK
After unbinding, the backend sends an unbound command to the device via MQTT. The device clears its screen and re-requests a claim code.
Errors:
| Code | Cause |
|---|---|
401 |
Missing or invalid access token |
403 |
Authenticated user is not the device owner |
404 |
Device not found |
POST /api/devices/{thing}/cmd¶
:material-lock: Requires authentication --- owner only
Send a command to a device. The command is delivered via MQTT to the device's down/cmd topic.
Path Parameters:
| Parameter | Description |
|---|---|
thing |
The device's thingName |
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
kind |
string | Yes | Command type (currently only text is supported for user commands) |
text |
string | Conditional | Required when kind is text |
Response: 200 OK
{
"id": "01912345-9999-7abc-def0-aaaaaaaaaaaa",
"deviceId": "01912345-6789-7abc-def0-123456789abc",
"kind": "text",
"text": "Hello from Inklet!",
"createdAt": "2026-01-16T09:00:00Z"
}
The response includes the DeviceCommand record, which tracks command delivery.
Errors:
| Code | Cause |
|---|---|
400 |
Missing or invalid command fields |
401 |
Missing or invalid access token |
403 |
Authenticated user is not the device owner |
404 |
Device not found |
POST /api/devices/{thing}/refresh-code¶
:material-lock: Requires authentication
Regenerate the claim code for a device. This is used when the current claim code has expired or was lost.
Path Parameters:
| Parameter | Description |
|---|---|
thing |
The device's thingName |
Response: 200 OK
Returns the full Device object with the new claimCode set.
Errors:
| Code | Cause |
|---|---|
401 |
Missing or invalid access token |
404 |
Device not found |
Error Responses¶
All error responses follow a consistent format:
| Code | Description |
|---|---|
400 |
Bad request --- malformed body, missing required fields, or invalid format |
401 |
Unauthorized --- access token is missing, expired, or invalid |
403 |
Forbidden --- the authenticated user does not own the target device |
404 |
Not found --- the device or resource does not exist |
409 |
Conflict --- the device is already bound to another user |
410 |
Gone --- the claim code has expired and must be regenerated |