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",
"nickname": "Living Room Display",
"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",
"tags": ["undeclared"],
"latestPushId": "01912345-aaaa-7abc-def0-bbbbbbbbbbbb",
"latestPushAt": "2026-01-16T09:00: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) |
nickname |
string or null | User-defined display name (if not set, clients should fall back to hwId) |
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 |
tags |
string array | User-defined tags for the device (default: ["undeclared"]) |
latestPushId |
UUID or null | ID of the most recently published push |
latestPushAt |
timestamp or null | When the latest push was published to the device |
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¶
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",
"nickname": "Living Room Display",
"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",
"tags": ["undeclared"],
"latestPushId": "01912345-aaaa-7abc-def0-bbbbbbbbbbbb",
"latestPushAt": "2026-01-16T09:00:00Z"
}
]
Returns an empty array [] if the user has no bound devices. Optional fields (nickname, latestPushId, latestPushAt, etc.) are omitted when null (omitempty).
GET /api/devices/{id}¶
Requires authentication --- owner only
Retrieve detailed information for a specific device by its Thing name.
Path Parameters:
| Parameter | Description |
|---|---|
id |
The device's UUID (e.g., 01912345-6789-7abc-def0-123456789abc) |
Response: 200 OK
{
"id": "01912345-6789-7abc-def0-123456789abc",
"hwId": "a1b2c3d4-5678-9012-abcd-ef0123456789",
"thingName": "inklet-a1b2c3d4",
"nickname": "Living Room Display",
"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",
"state": "{\"screen\":\"text\",\"lastCmd\":\"abc123\"}",
"stateUpdatedAt": "2026-01-16T08:25:00Z",
"tags": ["undeclared"],
"latestPushId": "01912345-aaaa-7abc-def0-bbbbbbbbbbbb",
"latestPushAt": "2026-01-16T09:00: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/{id}/state¶
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 |
|---|---|
id |
The device's UUID |
Response: 200 OK
Tip
Use this endpoint when you need to read the device state as a structured JSON object. The GET /api/devices/{id} 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 |
GET /api/devices/{id}/nickname¶
Requires authentication --- owner only
Get the device's display name. Returns the user-defined nickname if set, otherwise falls back to the device's hwId.
Path Parameters:
| Parameter | Description |
|---|---|
id |
The device's UUID |
Response: 200 OK
Errors:
| Code | Cause |
|---|---|
403 |
Authenticated user is not the device owner |
404 |
Device not found |
PUT /api/devices/{id}/nickname¶
Requires authentication --- owner only
Set or clear the device's display name. Send an empty string to clear the nickname (subsequent GET will return hwId).
Path Parameters:
| Parameter | Description |
|---|---|
id |
The device's UUID |
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
nickname |
string | Yes | Display name to set. Empty string "" clears the nickname |
Response: 200 OK
Errors:
| Code | Cause |
|---|---|
400 |
Invalid request body |
403 |
Authenticated user is not the device owner |
404 |
Device not found |
GET /api/devices/{id}/health¶
Requires authentication --- owner only
Get a real-time health snapshot of the device including connectivity, battery, firmware, and push status.
Path Parameters:
| Parameter | Description |
|---|---|
id |
The device's UUID |
Response: 200 OK
{
"id": "01912345-6789-7abc-def0-123456789abc",
"hwId": "a1b2c3d4-5678-9012-abcd-ef0123456789",
"nickname": "Living Room Display",
"online": true,
"state": "{\"screen\":\"text\"}",
"battery": 85,
"firmware": "1.2.0",
"lastSeenAt": "2026-01-16T08:30:00Z",
"stateUpdatedAt": "2026-01-16T08:25:00Z",
"latestPushId": "01912345-aaaa-7abc-def0-bbbbbbbbbbbb",
"latestPushAt": "2026-01-16T09:00:00Z"
}
| Field | Type | Description |
|---|---|---|
id |
UUID | Device ID |
hwId |
string | Hardware UUID |
nickname |
string | Display name (falls back to hwId if not set) |
online |
boolean | Whether the device is currently connected |
state |
JSON string or null | Device state reported via MQTT |
battery |
integer or null | Battery percentage |
firmware |
string or null | Firmware version |
lastSeenAt |
timestamp or null | Last heartbeat |
stateUpdatedAt |
timestamp or null | Last state update |
latestPushId |
UUID or null | Current push ID |
latestPushAt |
timestamp or null | When the latest push was published |
Errors:
| Code | Cause |
|---|---|
403 |
Authenticated user is not the device owner |
404 |
Device not found |
POST /api/devices/bind/nfc¶
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¶
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/{id}/unbind¶
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 |
|---|---|
id |
The device's UUID |
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/{id}/cmd¶
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 |
|---|---|
id |
The device's UUID |
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",
"thingName": "inklet-a1b2c3d4",
"kind": "text",
"payload": "{\"text\":\"Hello from Inklet!\"}",
"status": "delivered",
"createdAt": "2026-01-16T09:00:00Z"
}
The response is a DeviceCommand record for tracking command delivery. payload is a JSON string containing the full command parameters; status is delivered on success.
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/{id}/refresh-code¶
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 |
|---|---|
id |
The device's UUID |
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 |
GET /api/devices/{id}/push¶
Requires authentication --- owner only
Get the current push for a device. Returns a JSON object containing a CloudFront signed URL for the rendered bitmap. If the device already has a latest push, that one is returned (changed: false). Otherwise the backend promotes the highest-priority push in QUEUE status, marks it PUBLISHED, and sets it as the device's latest push (changed: true).
Path Parameters:
| Parameter | Description |
|---|---|
id |
The device's UUID |
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
format |
string | png |
Bitmap format the returned url points to. One of png, raw2, raw4 (see below). Unknown values fall back to png. |
Bitmap formats:
| Format | File | Description |
|---|---|---|
png |
image.png |
1-bit Floyd--Steinberg dithered PNG (800×480) |
raw2 |
image2.raw |
1 bpp packed bitmap (48000 bytes), black/white |
raw4 |
image4.raw |
2 bpp packed bitmap (96000 bytes), 4 grayscale levels |
Response: 200 OK
{
"url": "https://cdn.iminklet.com/render/{userId}/{deviceId}/{pushId}/image.png?Expires=...&Signature=...&Key-Pair-Id=...",
"pushId": "01912345-aaaa-7abc-def0-bbbbbbbbbbbb",
"status": "PUBLISHED",
"changed": false
}
| Field | Type | Description |
|---|---|---|
url |
string | CloudFront signed URL for the bitmap (expires in 15 minutes) |
pushId |
UUID | ID of the push record |
status |
string | Push status (PUBLISHED) |
changed |
boolean | true if a new push was promoted from the queue; false if the existing latest push was returned |
Errors:
| Code | Cause |
|---|---|
400 |
Invalid device id |
401 |
Missing or invalid access token |
403 |
Authenticated user is not the device owner |
404 |
Device not found, or no push is available (neither latest nor queued) |
POST /api/devices/{id}/push/refresh¶
Requires authentication --- owner only
Rotate to the next push in the queue. The backend finds the next highest-priority push in QUEUE status, marks the device's previous latest push as EXPIRED, marks the new push as PUBLISHED, sets it as the device's latest push, and returns its signed URL. If no queued push is available, it returns the current latest push instead (changed: false).
Path Parameters:
| Parameter | Description |
|---|---|
id |
The device's UUID |
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
format |
string | png |
Bitmap format the returned url points to. One of png, raw2, raw4. Unknown values fall back to png. |
Response: 200 OK
{
"url": "https://cdn.iminklet.com/render/{userId}/{deviceId}/{pushId}/image.png?Expires=...&Signature=...&Key-Pair-Id=...",
"pushId": "01912345-cccc-7abc-def0-dddddddddddd",
"status": "PUBLISHED",
"changed": true
}
| Field | Type | Description |
|---|---|---|
url |
string | CloudFront signed URL for the new bitmap |
pushId |
UUID | ID of the push record |
status |
string | Push status (PUBLISHED) |
changed |
boolean | true if a new push was promoted from the queue; false if no new push was available and the current one was returned |
Behavior:
- Finds the next highest-priority push in
QUEUEstatus for this device - If found: marks the previous
latestPushIdasEXPIRED, marks the new pushPUBLISHED, updateslatestPushId, returns the new push (changed: true) - If no queued push is available: returns the current latest push (
changed: false)
Errors:
| Code | Cause |
|---|---|
400 |
Invalid device id |
401 |
Missing or invalid access token |
403 |
Authenticated user is not the device owner |
404 |
Device not found, or nothing is available (no current push and no queued push) |
GET /api/devices/{id}/push/{pushId}¶
Requires authentication --- owner only
Get a specific push by its push ID. Works like GET /api/devices/{id}/push, but instead of resolving the current/queued push it returns the signed URL for the exact pushId — useful for re-displaying a push from history. Does not change any push state.
Path Parameters:
| Parameter | Description |
|---|---|
id |
The device's UUID |
pushId |
The push's UUID. Must belong to this device. |
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
format |
string | png |
Bitmap format the returned url points to. One of png, raw2, raw4. Unknown values fall back to png. |
Response: 200 OK
{
"url": "https://cdn.iminklet.com/render/{userId}/{deviceId}/{pushId}/image.png?Expires=...&Signature=...&Key-Pair-Id=...",
"pushId": "01912345-aaaa-7abc-def0-bbbbbbbbbbbb",
"status": "CONFIRMED",
"changed": false
}
Errors:
| Code | Cause |
|---|---|
400 |
Invalid device id or push id |
401 |
Missing or invalid access token |
403 |
Authenticated user is not the device owner |
404 |
Device not found, or push not found for this device, or push has no rendered file |
GET /api/devices/{id}/pushes¶
Requires authentication --- owner only
List all of the device's pushes, newest first. Uses cursor (keyset) pagination — well suited to infinite-scroll / lazy loading — and is filterable by status and created-at range.
Path Parameters:
| Parameter | Description |
|---|---|
id |
The device's UUID |
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
status |
string | — | Filter by push status (PREPARE, QUEUE, PUBLISHED, CONFIRMED, EXPIRED). Omit for all. |
from |
RFC3339 timestamp | — | Only pushes created at or after this time |
to |
RFC3339 timestamp | — | Only pushes created at or before this time |
cursor |
string | — | Opaque cursor from a previous response's nextCursor. Omit for the first page. |
limit |
integer | 20 |
Page size (max 50) |
Response: 200 OK
{
"items": [
{
"pushId": "01912345-aaaa-7abc-def0-bbbbbbbbbbbb",
"title": "Daily Summary",
"summary": "Today's highlights include Rust memory safety and a WebAssembly article.",
"status": "CONFIRMED",
"priority": 10,
"createdAt": "2026-05-26T09:00:00Z"
}
],
"nextCursor": "MjAyNi0wNS0yNlQwOTowMDowMFo...",
"hasMore": true
}
| Field | Type | Description |
|---|---|---|
items[].pushId |
UUID | ID of the push record |
items[].title |
string | Short headline (may be empty) |
items[].summary |
string | 1--2 sentence description (may be empty) |
items[].status |
string | Push status |
items[].priority |
integer | Push priority |
items[].createdAt |
timestamp | When the push was created |
nextCursor |
string | Cursor for the next page; omitted when hasMore is false |
hasMore |
boolean | true if more pushes remain after this page |
Infinite scroll
Load the first page without cursor. When the user scrolls near the bottom, if hasMore is true, request the next page with ?cursor={nextCursor}. Stop when hasMore is false. Cursor pagination is anchored to (createdAt, id), so newly created or rotated pushes don't shift already-loaded pages (no duplicates/skips) and deep pages stay fast.
Combine with GET /api/devices/{id}/push/{pushId} to fetch the bitmap URL for any entry, or POST /api/devices/{id}/current-push to make one the device's current push.
Errors:
| Code | Cause |
|---|---|
400 |
Invalid device id, or malformed cursor |
401 |
Missing or invalid access token |
403 |
Authenticated user is not the device owner |
404 |
Device not found |
POST /api/devices/{id}/current-push¶
Requires authentication --- owner only
Force the device's current push to a specific push. Marks the device's previous latest push back to QUEUE (un-pushed), marks the target push PUBLISHED, sets it as the device's latest, and sends an MQTT new_push signal so the device fetches it immediately.
Path Parameters:
| Parameter | Description |
|---|---|
id |
The device's UUID |
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
pushId |
UUID | Yes | The push to make current. Must belong to this device and have a rendered file. |
Response: 200 OK
Errors:
| Code | Cause |
|---|---|
400 |
Invalid device id or push id |
401 |
Missing or invalid access token |
403 |
Authenticated user is not the device owner |
404 |
Device not found, or push not found for this device, or push not yet rendered |
POST /api/devices/{id}/custom-push/upload¶
Requires authentication --- owner only
Step 1 of uploading a custom image to display. Returns a presigned S3 upload ticket (image only, ≤10 MB, 15-minute expiry). The frontend uploads the image directly to S3, then calls the confirm endpoint.
Path Parameters:
| Parameter | Description |
|---|---|
id |
The device's UUID |
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
contentType |
string | Yes | MIME type, must be image/* |
sizeBytes |
integer | No | Declared size; rejected if over 10 MB |
Response: 200 OK
{
"fileId": "01912345-2b44-7c2b-8e36-d367ecab52b7",
"url": "https://inklet-dev.s3.us-east-1.amazonaws.com",
"fields": { "key": "user/.../custom.png", "...": "..." },
"expiresAt": "2026-05-30T13:47:09Z"
}
Upload to S3 as a multipart/form-data POST (all fields first, then the file field), as with content uploads.
Errors:
| Code | Cause |
|---|---|
400 |
Invalid device id, or content type not an image |
401 |
Missing or invalid access token |
403 |
Authenticated user is not the device owner |
413 |
File exceeds the 10 MB limit |
POST /api/devices/{id}/custom-push/confirm¶
Requires authentication --- owner only
Step 2: confirm the uploaded image and create a push for it. The backend verifies the S3 upload, creates a push + render task pointing at the internal __custom_image__ template, and enqueues rendering. The render worker downloads the image, resizes to 800×480, and produces the dithered/grayscale bitmaps.
The returned push starts in PREPARE, then becomes QUEUE once rendered. Poll GET /api/devices/{id}/pushes (or GET /push/{pushId}) for status, then call POST /api/devices/{id}/current-push to display it.
Path Parameters:
| Parameter | Description |
|---|---|
id |
The device's UUID |
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
fileId |
UUID | Yes | The fileId from the upload step |
title |
string | No | Optional headline shown in push lists |
Response: 200 OK
Errors:
| Code | Cause |
|---|---|
400 |
Invalid device id or file id |
401 |
Missing or invalid access token |
403 |
Authenticated user is not the device owner |
404 |
Device or file not found |
409 |
File not in a confirmable state |
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 |