Skip to content

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:

Authorization: Bearer {accessToken}

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

{
  "screen": "text",
  "lastCmd": "abc123",
  "brightness": 50
}

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

{
  "nickname": "Living Room Display"
}

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:

{
  "nickname": "Living Room Display"
}
Field Type Required Description
nickname string Yes Display name to set. Empty string "" clears the nickname

Response: 200 OK

{
  "message": "nickname updated"
}

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:

inklet://bind?v=2&kid=<keyId>&hw=<hwId>&cv=<credentialVersion>&p=<proof>

Response: 200 OK

{
  "device": { ... },
  "status": "bound"
}
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

{
  "message": "device unbound"
}

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:

{
  "kind": "text",
  "text": "Hello from Inklet!"
}
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:

  1. Finds the next highest-priority push in QUEUE status for this device
  2. If found: marks the previous latestPushId as EXPIRED, marks the new push PUBLISHED, updates latestPushId, returns the new push (changed: true)
  3. 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:

{ "pushId": "01912345-aaaa-7abc-def0-bbbbbbbbbbbb" }
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

{ "message": "current push set" }

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:

{ "contentType": "image/png", "sizeBytes": 204800 }
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:

{ "fileId": "01912345-2b44-7c2b-8e36-d367ecab52b7", "title": "Vacation photo" }
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

{ "pushId": "01912345-cccc-7abc-def0-dddddddddddd" }

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:

{
  "error": "device not found"
}
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