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 v2 credential. The backend derives a per-device key via HKDF-SHA256 and verifies the 128-bit HMAC proof before binding. Credentials are issued by the factory console and written onto NFC tags; the App parses the tag URI and submits the structured fields to this endpoint.
Request Body:
{
"version": 2,
"keyId": "k1",
"hwId": "a1b2c3d4e5f60708090a0b0c0d0e0f10",
"credentialVersion": 1,
"proof": "base64url-encoded-16-bytes"
}
| Field | Type | Required | Description |
|---|---|---|---|
version |
integer | Yes | Must be 2 (v1 is retired) |
keyId |
string | Yes | Master key ID parsed from the NFC tag |
hwId |
string | Yes | Device hardware UUID (32 hex characters, no hyphens) |
credentialVersion |
integer | Yes | Credential version (incremented on revoke-and-reissue) |
proof |
string | Yes | Base64url-encoded 128-bit truncated HMAC (16 bytes) |
The raw payload written on the NFC tag is a URI the App should parse before calling this endpoint:
Response: 200 OK
| Field | Type | Description |
|---|---|---|
device |
object | Complete device object with ownerId set to the authenticated user |
status |
string | "bound" on a fresh pairing; "already_bound" when the same user re-taps a tag they already own (idempotent) |
Errors:
| Code | Cause |
|---|---|
400 |
Malformed credential, proof mismatch, unsupported version, or unknown keyId (collapsed to prevent enumeration) |
404 |
Device not registered in the factory console |
409 |
Device already bound to a different user |
NFC v1 format is retired
The old inklet:1:{hwId}:{signature} format (64-bit HMAC under a single shared FACTORY_SECRET) is no longer accepted. Each device now has its own derived key (HKDF-SHA256) and the NFC payload uses URI format.
POST /api/devices/bind/code — Removed¶
410 Gone
Claim-code binding is retired. This endpoint permanently returns 410 Gone. Migrate to POST /api/devices/bind/nfc (NFC v2).
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 — Removed¶
410 Gone
Claim-code refresh is retired. This endpoint permanently returns 410 Gone. Device pairing is now NFC-only.
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 |