Skip to content

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

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:

  1. Creates the device record in the database if it does not exist (using hwId and thingName)
  2. Updates firmware, battery, lastSeenAt, and online status
  3. If the device is unbound and has no claim code, the backend generates one and sends a claim_code command

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:

  1. Stores the full JSON payload as the device's state
  2. 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:

  1. If the device is already bound to a user, sends an already_bound command
  2. If the device is unbound, generates a 6-character alphanumeric claim code
  3. Stores the code in the database
  4. Sends a claim_code command 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:

{
  "token": "eyJhbGciOiJIUzI1NiIs..."
}
Field Type Description
token string Binding token read from the NFC tag

Backend Behavior:

  1. Validates the token and resolves the target user
  2. If successful: binds the device to the user, sends a bound command to the device
  3. If it fails: sends a bind_failed command to the device (with a reason field)

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:

{ "format": "png" }
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:

  1. If the device has a latestPushId, responds with the current push URL (changed: false)
  2. If no latest push exists, finds the highest-priority push in QUEUE status
  3. Marks the found push as PUBLISHED, sets it as the device's latest push
  4. Responds with a push_response command containing the URL (changed: true)
  5. 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:

{ "format": "png" }
Field Type Default Description
format string png Requested bitmap format, same as get_push.

Backend Behavior:

  1. Finds the next highest-priority push in QUEUE status for this device
  2. If found: replaces the device's latestPushId, marks it PUBLISHED, responds with push_response (changed: true)
  3. 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:

  1. Looks up the device's latestPushId
  2. Updates that push status from PUBLISHED to CONFIRMED

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.

{
  "kind": "text",
  "id": "01912345-9999-7abc-def0-aaaaaaaaaaaa",
  "text": "Hello from Inklet!"
}
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.

{
  "kind": "claim_code",
  "code": "A3X9K2"
}
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.

{
  "kind": "bound",
  "userId": "01912345-0000-7abc-def0-000000000001"
}
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.

{
  "kind": "unbound"
}
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.

{
  "kind": "already_bound"
}
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):

{
  "kind": "push_response",
  "error": "no push available"
}
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.

{
  "kind": "bind_failed",
  "reason": "invalid bind token"
}
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.

{
  "kind": "new_push"
}
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.