IoT Protocol (MQTT)¶
Inklet devices communicate with the backend over MQTT via AWS IoT Core. All connections use X.509 certificate mutual TLS (mTLS) for authentication. This page documents the topic structure, message formats, and security policies.
Connection Details¶
| Parameter | Value |
|---|---|
| Protocol | MQTT over TLS (port 8883) |
| Authentication | X.509 client certificates (mTLS) |
| Broker | AWS IoT Core (xxxx-ats.iot.us-east-1.amazonaws.com) |
| QoS | 1 (at least once delivery) |
Topic Structure¶
All topics follow the pattern inklet/dev/{thingName}/direction/type, where {thingName} is the AWS IoT Core Thing name assigned during provisioning.
inklet/dev/{thingName}/
├── up/ # Device → Backend
│ ├── heartbeat # Periodic health report
│ ├── state # Arbitrary device state
│ ├── request_claim # Request a pairing code
│ ├── bind_token # App-less binding via NFC token
│ ├── get_push # Request current push bitmap URL
│ ├── refresh_push # Request next push
│ └── confirm_push # Confirm push displayed
└── down/ # Backend → Device
└── cmd # Commands from the backend
Uplink Topics (Device to Backend)¶
inklet/dev/{thingName}/up/heartbeat¶
Periodic health report sent by the device at a configurable interval (default: 30 seconds).
Payload:
{
"hwId": "a1b2c3d4-5678-9012-abcd-ef0123456789",
"ts": 1705395000,
"firmware": "1.2.0",
"battery": 85
}
| Field | Type | Description |
|---|---|---|
hwId |
string | Hardware UUID |
ts |
integer | Unix timestamp (seconds) |
firmware |
string | Firmware version string |
battery |
integer | Battery percentage (0--100) |
Backend Behavior:
- Creates the device record in the database if it does not exist (using
hwIdandthingName) - Updates
firmware,battery,lastSeenAt, andonlinestatus - If the device is unbound and has no claim code, the backend generates one and sends a
claim_codecommand
inklet/dev/{thingName}/up/state¶
Device reports arbitrary state as a JSON object. The backend stores this as a JSON string in the state column.
Payload:
{
"screen": "text",
"lastCmd": "01912345-9999-7abc-def0-aaaaaaaaaaaa",
"brightness": 50,
"temperature": 22.5
}
The payload can contain any valid JSON. The backend stores it without interpreting its contents.
Backend Behavior:
- Stores the full JSON payload as the device's
state - Updates
stateUpdatedAt
inklet/dev/{thingName}/up/request_claim¶
Device requests a claim code for user pairing. Sent when the device is unbound and needs to display a pairing code.
Payload:
Backend Behavior:
- If the device is already bound to a user, sends an
already_boundcommand - If the device is unbound, generates a 6-character alphanumeric claim code
- Stores the code in the database
- Sends a
claim_codecommand back to the device
inklet/dev/{thingName}/up/bind_token¶
Device initiates app-less binding via an NFC token. After reading a token from an NFC tag, the device sends this message to ask the backend to complete the bind.
Payload:
| Field | Type | Description |
|---|---|---|
token |
string | Binding token read from the NFC tag |
Backend Behavior:
- Validates the token and resolves the target user
- If successful: binds the device to the user, sends a
boundcommand to the device - If it fails: sends a
bind_failedcommand to the device (with areasonfield)
inklet/dev/{thingName}/up/get_push¶
Device requests the current push bitmap URL. The backend responds with a push_response command on the device's downlink topic.
Payload:
| Field | Type | Default | Description |
|---|---|---|---|
format |
string | png |
Requested bitmap format: png (1-bit dithered PNG), raw2 (1 bpp packed), raw4 (2 bpp 4-level grayscale). Unknown values fall back to png. |
Backend Behavior:
- If the device has a
latestPushId, responds with the current push URL (changed: false) - If no latest push exists, finds the highest-priority push in
QUEUEstatus - Marks the found push as
PUBLISHED, sets it as the device's latest push - Responds with a
push_responsecommand containing the URL (changed: true) - If no push is available, no response is sent
inklet/dev/{thingName}/up/refresh_push¶
Device requests the next push from the queue. The backend promotes the next highest-priority QUEUE push and responds with a push_response command.
Payload:
| Field | Type | Default | Description |
|---|---|---|---|
format |
string | png |
Requested bitmap format, same as get_push. |
Backend Behavior:
- Finds the next highest-priority push in
QUEUEstatus for this device - If found: replaces the device's
latestPushId, marks itPUBLISHED, responds withpush_response(changed: true) - If no new push is available: responds with the current push URL (
changed: false)
inklet/dev/{thingName}/up/confirm_push¶
Device confirms that a push has been successfully displayed on its e-ink screen. The backend updates the device's current latest push (latestPushId) to CONFIRMED.
Payload:
The backend does not read the payload. It always confirms the device's latestPushId.
Backend Behavior:
- Looks up the device's
latestPushId - Updates that push status from
PUBLISHEDtoCONFIRMED
Downlink Topics (Backend to Device)¶
inklet/dev/{thingName}/down/cmd¶
Commands from the backend to the device. All commands share a common structure with a kind field that determines the command type.
Command: text¶
Send text content for the device to render on its e-ink display.
| Field | Type | Description |
|---|---|---|
kind |
string | "text" |
id |
UUID | Unique command ID for tracking |
text |
string | Text content to render |
Command: claim_code¶
Send a pairing code for the device to display. Users enter this code to bind the device to their account.
| Field | Type | Description |
|---|---|---|
kind |
string | "claim_code" |
code |
string | 6-character alphanumeric claim code |
Command: bound¶
Notify the device that it has been bound to a user.
| Field | Type | Description |
|---|---|---|
kind |
string | "bound" |
userId |
UUID | ID of the user who bound the device |
Command: unbound¶
Notify the device that it has been unbound from its owner. The device should clear its screen and re-request a claim code.
| Field | Type | Description |
|---|---|---|
kind |
string | "unbound" |
Command: already_bound¶
Sent when a device requests a claim code but is already bound to a user. The device should not display a pairing screen.
| Field | Type | Description |
|---|---|---|
kind |
string | "already_bound" |
Command: push_response¶
Send the result of a push request (get_push or refresh_push) back to the device. Contains the bitmap URL for the device to download and display.
On success:
{
"kind": "push_response",
"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": true
}
| Field | Type | Description |
|---|---|---|
kind |
string | "push_response" |
url |
string | CloudFront signed URL for the push bitmap (expires in 15 minutes). The actual path follows the pattern render/{userId}/{deviceId}/{pushId}/image.png (or image2.raw / image4.raw depending on the requested format) |
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 push was returned |
On error (no push available or internal failure):
| Field | Type | Description |
|---|---|---|
kind |
string | "push_response" |
error |
string | Error reason |
Command: bind_failed¶
Sent when a device's bind_token request fails. The device should indicate to the user that binding failed.
| Field | Type | Description |
|---|---|---|
kind |
string | "bind_failed" |
reason |
string | Failure reason (e.g. "bad_payload", "invalid bind token") |
Command: new_push¶
Signal that the device's current push changed out-of-band (e.g. the user pressed refresh in the frontend, set a specific push as current, or uploaded a custom image). The device should immediately respond with a get_push request to fetch the new latest push — get_push does not rotate the queue, so the device lands exactly on what the backend set as current.
| Field | Type | Description |
|---|---|---|
kind |
string | "new_push" |
Why get_push, not refresh_push
The backend already chose the target push before sending new_push. Responding with get_push returns that exact push; responding with refresh_push would rotate the queue again and could skip past it.
AWS IoT Core Policies¶
Three IAM policies govern MQTT access. Each enforces the principle of least privilege.
inklet-device-policy¶
Per-device isolation policy attached to each device certificate. Uses the IoT Core policy variable ${iot:Connection.Thing.ThingName} so a device can only access its own topics.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "iot:Connect",
"Resource": "arn:aws:iot:us-east-1:*:client/${iot:Connection.Thing.ThingName}"
},
{
"Effect": "Allow",
"Action": "iot:Publish",
"Resource": [
"arn:aws:iot:us-east-1:*:topic/inklet/dev/${iot:Connection.Thing.ThingName}/up/*"
]
},
{
"Effect": "Allow",
"Action": "iot:Subscribe",
"Resource": [
"arn:aws:iot:us-east-1:*:topicfilter/inklet/dev/${iot:Connection.Thing.ThingName}/down/*"
]
},
{
"Effect": "Allow",
"Action": "iot:Receive",
"Resource": [
"arn:aws:iot:us-east-1:*:topic/inklet/dev/${iot:Connection.Thing.ThingName}/down/*"
]
}
]
}
Per-Device Isolation
The ${iot:Connection.Thing.ThingName} variable is resolved by AWS IoT Core at connection time. A device certificate associated with Thing inklet-a1b2c3d4 can only publish to inklet/dev/inklet-a1b2c3d4/up/* and subscribe to inklet/dev/inklet-a1b2c3d4/down/*. It cannot access other devices' topics.
inklet-backend-policy¶
Privileged policy for the backend MQTT client (inklet-backend). The backend subscribes to all device uplink topics and publishes commands to any device's downlink topic.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "iot:Connect",
"Resource": "arn:aws:iot:us-east-1:*:client/inklet-backend"
},
{
"Effect": "Allow",
"Action": "iot:Subscribe",
"Resource": [
"arn:aws:iot:us-east-1:*:topicfilter/inklet/dev/+/up/*"
]
},
{
"Effect": "Allow",
"Action": "iot:Receive",
"Resource": [
"arn:aws:iot:us-east-1:*:topic/inklet/dev/+/up/*"
]
},
{
"Effect": "Allow",
"Action": "iot:Publish",
"Resource": [
"arn:aws:iot:us-east-1:*:topic/inklet/dev/+/down/*"
]
}
]
}
inklet-claim-policy¶
Restricted policy for claim certificates used during Fleet Provisioning. Claim certs can only perform the Fleet Provisioning MQTT transactions --- they cannot publish or subscribe to application topics.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "iot:Connect",
"Resource": "*"
},
{
"Effect": "Allow",
"Action": "iot:Publish",
"Resource": [
"arn:aws:iot:us-east-1:*:topic/$aws/certificates/create/*",
"arn:aws:iot:us-east-1:*:topic/$aws/provisioning-templates/*/provision/*"
]
},
{
"Effect": "Allow",
"Action": "iot:Subscribe",
"Resource": [
"arn:aws:iot:us-east-1:*:topicfilter/$aws/certificates/create/*",
"arn:aws:iot:us-east-1:*:topicfilter/$aws/provisioning-templates/*/provision/*"
]
},
{
"Effect": "Allow",
"Action": "iot:Receive",
"Resource": [
"arn:aws:iot:us-east-1:*:topic/$aws/certificates/create/*",
"arn:aws:iot:us-east-1:*:topic/$aws/provisioning-templates/*/provision/*"
]
}
]
}
Claim Certificate Security
Claim certificates are shared across all devices and only grant access to the Fleet Provisioning topics. They should be stored securely but are not as sensitive as per-device certificates --- a compromised claim cert can only create new Things, not impersonate existing ones.
Fleet Provisioning¶
New devices use Fleet Provisioning by Claim to obtain their device-specific certificates on first boot.
Flow:
Device (first boot)
│
├── Connects to IoT Core with claim certificate
│
├── Publishes to $aws/certificates/create/json
│ └── Receives new certificate + private key
│
├── Publishes to $aws/provisioning-templates/{template}/provision/json
│ └── Sends: { "SerialNumber": "{hwId}" }
│ └── Receives: { "thingName": "inklet-{prefix}" }
│
├── Stores device cert, private key, and thingName
│
└── Reconnects with device certificate
└── Begins normal operation (heartbeats, commands)
After provisioning, the device stores its certificates and Thing name locally and never uses the claim certificate again.