Skip to content

Inklet SDK v0.1

The /api/sdk/v1 surface is what the server-side JS/TS SDK talks to. It is an additive facade: it reads the same devices, pushes and files the Portal, iOS app and panels use, and presents them under three nouns of its own.

SDK noun What it is
Display A panel you own.
Content One submission: text, links, images, files, or a combination.
Presentation One immutable rendered result: targeted at one Display (Display Presentation), or a standalone Scene + PNG renditions generated without a Display (Targetless Presentation).

Targetless Presentations

In v0.1, POST /contents accepts an optional output field. Supplying it generates a Presentation (inklet Scene v1 + PNG renditions) without binding any Display. See Targetless Presentations.

Base URL

https://dev.iminklet.com/api/sdk/v1

One push request creates exactly one Content, and a Content is not a display job. It may produce several Presentations — one per Display it reaches — and each is immutable once created.


Quickstart

Push an image to a specific Display in five steps.

Step 1 — Get a Personal Access Token

Create one at /api/personal-access-tokens (see Personal Access Tokens). It looks like il_pat_....

Step 2 — List your Displays

const BASE = "https://dev.iminklet.com/api/sdk/v1";
const PAT  = "il_pat_...";

const res = await fetch(`${BASE}/displays`, {
  headers: { Authorization: `Bearer ${PAT}` },
});
const { items } = await res.json();
const displayId = items[0].id;          // pick a Display

Step 3 — Create Content and mint upload tickets

Hardcode mode: one PNG/JPEG image pushed straight to a Display.

const { v4: uuidv4 } = require("uuid");

const create = await fetch(`${BASE}/contents`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${PAT}`,
    "Content-Type": "application/json",
    "Idempotency-Key": uuidv4(),         // 8–128 printable ASCII
  },
  body: JSON.stringify({
    mode: "hardcode",
    displayId,
    assets: [{
      type: "image",
      filename: "panel.png",
      contentType: "image/png",
      sizeBytes: 204800,
    }],
  }),
});

const { content, uploadTickets } = await create.json();
// content.id  → your Content's UUID
// uploadTickets[0] → presigned S3 POST for assets[0]

Step 4 — Upload the binary asset to S3

Never send your PAT to the presigned origin

Use only the fields from the ticket — no Authorization header.

const ticket = uploadTickets[0];   // one per binary asset
const form   = new FormData();

for (const [k, v] of Object.entries(ticket.fields)) {
  form.append(k, v as string);
}
form.append("file", fs.createReadStream("./panel.png"));   // must be last

await fetch(ticket.url, { method: "POST", body: form });   // no Auth header

The ticket expires at ticket.expiresAt. If it expires, call POST /contents/{contentId}/upload-tickets to refresh.

Step 5 — Confirm uploads and poll for ready

// Trigger processing
const confirmRes = await fetch(
  `${BASE}/contents/${content.id}/confirm`,
  {
    method: "POST",
    headers: { Authorization: `Bearer ${PAT}` },
  }
);
let current = await confirmRes.json();

// Poll until the Content leaves processing
while (current.state === "processing") {
  await new Promise(r => setTimeout(r, 2000));
  const poll = await fetch(`${BASE}/contents/${current.id}`, {
    headers: { Authorization: `Bearer ${PAT}` },
  });
  current = await poll.json();
}

if (current.state === "ready") {
  console.log("Presentation IDs:", current.presentationIds);
} else {
  console.error("Failed:", current.processing.error);
}

When state reaches ready, presentationIds is populated. The panel fetches and confirms the Presentation on its own schedule — ready means the backend has queued the render, not that the panel is showing it yet.


Authentication

User-scoped Personal Access Tokens only:

Authorization: Bearer il_pat_...

Two differences from the rest of the backend are deliberate:

  • A session JWT is rejected here. It authenticates everywhere else; under this prefix it is a 401.
  • X-Renewed-Token is never issued. Sliding renewal does not apply to a PAT.

Every failure — missing header, wrong scheme, a JWT, an unknown, expired or revoked PAT — returns the same 401 authentication_failed body, so the response cannot be used to discover which tokens exist.

PAT lifecycle (POST / DELETE /api/personal-access-tokens) stays outside the SDK facade.


Conventions

  • JSON in and out; UUID strings; RFC3339 UTC timestamps.
  • Optional values are an explicit null. Stable arrays are [], never null.
  • Unknown request fields are ignored; an unknown enum value is 400 invalid_request.

Request IDs

Every response carries X-Request-Id, and every error repeats it in requestId. Quote it when filing a bug.

The backend generates it. A caller-supplied X-Request-Id is ignored, not echoed: reflecting it would allow header injection. Use the id returned on your own response.

Error envelope

{
  "error": {
    "code": "invalid_request",
    "message": "Safe developer-facing message.",
    "requestId": "req_2f8c1d0a4b6e8f0a2c4d6e80",
    "details": {}
  }
}

Branch on code. The message wording is not part of the contract and may change. details is omitted when empty.

Idempotency

POST /contents requires an Idempotency-Key header, 8–128 printable ASCII characters.

  • Scoped by user + method + route; the request body is fingerprinted with SHA-256.
  • Results are retained 24 hours.
  • Same key, same body, inside the window → the original response is replayed verbatim, including the original upload tickets. No side effects run twice.
  • Same key, different body → 409 idempotency_conflict.
  • Same key while the first request is still running → 409 invalid_state (the result is not ready to replay yet).
  • Past 24 hours the key behaves as if never seen.

A rejected request releases its key, so you can fix the body and retry with the same key.

Replay never creates new Content or new processing jobs. If a replayed ticket has expired, refresh it via POST /contents/{contentId}/upload-tickets.

Pagination

Cursor-based, deterministic on (createdAt, id) descending. Default limit 20, maximum 50.

{ "items": [], "nextCursor": null, "hasMore": false }

An invalid cursor or an out-of-range limit is 400 invalid_request — not a silent fallback to page one. Treat the cursor as opaque and pass it back unmodified.


Displays

GET /displays

Paged list of the Displays you own.

curl -H "Authorization: Bearer $PAT" \
  "https://dev.iminklet.com/api/sdk/v1/displays?limit=20"

An empty list and a backend failure are different answers: owning no Displays is 200 {"items": []}, a failure is 500. A client must never show an empty dashboard because the backend was unreachable.

GET /displays/{displayId}

curl -H "Authorization: Bearer $PAT" \
  "https://dev.iminklet.com/api/sdk/v1/displays/$DISPLAY_ID"
  • Display exists but is not yours → 403 access_denied
  • No such Display → 404 display_not_found
  • displayId is not a UUID → 400 invalid_request

Display model

{
  "id": "019fd0cc-4d20-702e-a7c6-baae19b70d25",
  "hardwareId": "hw-abc123",
  "thingName": "inklet-abc123",
  "name": "Kitchen",
  "nickname": "Kitchen",
  "firmware": "1.4.2",
  "batteryPercent": 82,
  "online": true,
  "lastSeenAt": "2026-08-12T07:21:17Z",
  "stateUpdatedAt": "2026-08-12T07:20:55Z",
  "boundAt": "2026-06-01T09:12:00Z",
  "tags": ["kitchen"],
  "syncIntervalMinutes": null,
  "nextSyncAt": null,
  "currentPresentationId": "019fd0aa-...",
  "currentPresentationUpdatedAt": "2026-08-12T06:02:11Z",
  "pendingPresentationId": "019fd0bb-...",
  "capabilities": {
    "pixelWidth": 800,
    "pixelHeight": 480,
    "orientation": "landscape",
    "colorMode": "mono",
    "supportedImageContentTypes": ["image/png", "image/jpeg"],
    "supportedOutputFormats": ["png", "raw2", "raw4"]
  }
}

name falls back to thingName when no nickname is set — always non-empty.

syncIntervalMinutes and nextSyncAt are always null in v0.1

The fleet polls on a firmware-side schedule the backend neither stores nor controls. The fields exist so adding a source later is not a breaking change. Do not schedule against them.

Capabilities

For v0.1 every Display reports the same fixed profile: 800×480, landscape, mono. Capabilities are resolved behind a single provider seam, so a future per-SKU source will not change this payload's shape. Always read capabilities from the Display rather than hard-coding dimensions.

Current vs. pending

These are two separate questions:

Field Meaning
currentPresentationId Last Presentation the panel confirmed it is showing — what is on the glass.
pendingPresentationId A Presentation published to the Display but not yet confirmed. null once confirmed.

"Current" never means "the newest thing we sent". An unconfirmed Presentation may never have been fetched — the panel could be asleep. An offline Display keeps both references, its capabilities and its last-known state.


Contents

POST /contents

Requires Idempotency-Key. Creates the Content and returns a presigned S3 POST ticket for every binary asset. Text and link assets carry their payload inline and get no ticket.

const res = await fetch(`${BASE}/contents`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${PAT}`,
    "Content-Type": "application/json",
    "Idempotency-Key": uuidv4(),
  },
  body: JSON.stringify({
    mode: "auto",
    assets: [
      { type: "text", text: "Morning briefing" },
      {
        type: "image",
        filename: "chart.png",
        contentType: "image/png",
        sizeBytes: 102400,
      },
    ],
  }),
});
const { content, uploadTickets } = await res.json();
const res = await fetch(`${BASE}/contents`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${PAT}`,
    "Content-Type": "application/json",
    "Idempotency-Key": uuidv4(),
  },
  body: JSON.stringify({
    mode: "manual",
    displayId: "019fd0cc-...",
    assets: [
      { type: "link", url: "https://example.com/dashboard" },
    ],
  }),
});
const res = await fetch(`${BASE}/contents`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${PAT}`,
    "Content-Type": "application/json",
    "Idempotency-Key": uuidv4(),
  },
  body: JSON.stringify({
    mode: "hardcode",
    displayId: "019fd0cc-...",
    assets: [{
      type: "image",
      filename: "panel.png",
      contentType: "image/png",
      sizeBytes: 204800,
    }],
  }),
});
const res = await fetch(`${BASE}/contents`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${PAT}`,
    "Content-Type": "application/json",
    "Idempotency-Key": uuidv4(),
  },
  body: JSON.stringify({
    mode: "auto",
    assets: [{
      type: "file",
      filename: "report.pdf",
      contentType: "application/pdf",
      sizeBytes: 512000,
    }],
  }),
});
curl -X POST "$BASE/contents" \
  -H "Authorization: Bearer $PAT" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "mode": "hardcode",
    "displayId": "019fd0cc-...",
    "assets": [{
      "type": "image",
      "filename": "panel.png",
      "contentType": "image/png",
      "sizeBytes": 204800
    }]
  }'

Per-mode rules

Mode displayId output Assets
auto (Display) must not be given omitted one or more of any type
auto (targetless) forbidden (null or omitted) required one or more of any type
manual required, accessible to you forbidden one or more of any type
hardcode (Display) required, accessible to you omitted exactly one PNG or JPEG
hardcode (targetless) forbidden (null or omitted) required exactly one PNG or JPEG

The presence of output is the sole routing signal to the targetless branch. See Targetless Presentations.

Response 201:

{
  "content": { /* Content object */ },
  "uploadTickets": [
    {
      "assetIndex": 1,
      "url": "https://s3.amazonaws.com/...",
      "fields": {
        "key": "uploads/...",
        "AWSAccessKeyId": "...",
        "policy": "...",
        "signature": "...",
        "x-amz-security-token": "..."
      },
      "expiresAt": "2026-08-12T14:00:00Z"
    }
  ]
}

assetIndex is your original index into the assets array — preserved through tickets, failures and the stored Content so a failure at index 2 means the third asset you submitted.

Asset types

Type Required fields Accepted values
text text (non-whitespace)
link url (absolute HTTP/S, no credentials)
image filename, contentType, sizeBytes image/png, image/jpeg, image/gif, image/webp, image/svg+xml
file filename, contentType, sizeBytes application/pdf, text/plain, text/markdown, application/json

Maximum 10 MiB per binary asset (413 asset_too_large), maximum 50 assets per request. An unrecognised contentType is 400 invalid_asset; an unknown type is 400 invalid_request. The declared type must agree with contentType — declaring a PDF as image is rejected.

Upload binary assets to S3

async function uploadAsset(ticket: UploadTicket, filePath: string) {
  const form = new FormData();

  // Append all presigned fields FIRST
  for (const [k, v] of Object.entries(ticket.fields)) {
    form.append(k, v);
  }

  // File must be last
  form.append("file", fs.createReadStream(filePath), {
    filename: path.basename(filePath),
  });

  const res = await fetch(ticket.url, {
    method: "POST",
    body: form,
    // NO Authorization header — the ticket is the credential
  });

  if (!res.ok) throw new Error(`S3 upload failed: ${res.status}`);
}

If a ticket expires before the upload completes, refresh it with POST /contents/{contentId}/upload-tickets (see below) — a replay of POST /contents still returns the original expired ticket.

POST /contents/{contentId}/confirm

Verifies every binary asset (HeadObject) and, on full success, enqueues exactly one processing job.

const res = await fetch(`${BASE}/contents/${contentId}/confirm`, {
  method: "POST",
  headers: { Authorization: `Bearer ${PAT}` },
});
const content = await res.json();
curl -X POST "$BASE/contents/$CONTENT_ID/confirm" \
  -H "Authorization: Bearer $PAT"
Outcome state upload.status
All assets present processing complete
Some assets missing pending partial — see upload.failedAssetIndexes
Already confirmed unchanged — returns existing Content, no duplicate job

Confirm is inherently retry-safe and needs no Idempotency-Key. Concurrent confirms enqueue exactly one job via an atomic database claim.

POST /contents/{contentId}/upload-tickets

Mint fresh tickets for binary assets that failed or whose ticket expired.

const res = await fetch(
  `${BASE}/contents/${contentId}/upload-tickets`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${PAT}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ assetIndexes: [2] }),  // your original indexes
  }
);
const { content, uploadTickets } = await res.json();
  • Text and link indexes are 400 invalid_request — they have no upload.
  • A Content that has already started processing is 409 invalid_state.

GET /contents/{contentId}

const res = await fetch(`${BASE}/contents/${contentId}`, {
  headers: { Authorization: `Bearer ${PAT}` },
});
const content = await res.json();

A Content that is not yours is 404 content_not_found. Unlike a Display, there is no 403 for Content — Content IDs are kept unprobeable.

GET /contents

const res = await fetch(
  `${BASE}/contents?state=ready&mode=hardcode&limit=10`,
  { headers: { Authorization: `Bearer ${PAT}` } }
);
const { items, nextCursor, hasMore } = await res.json();

Filters: state (pending, processing, ready, failed), mode (auto, manual, hardcode). An unknown value for either is 400 invalid_request rather than an empty page — a typo should be surfaced, not silently swallowed.

Content model

{
  "id": "019fd100-...",
  "mode": "hardcode",
  "requestedDisplayId": "019fd0cc-...",
  "intent": null,
  "title": null,
  "state": "ready",
  "output": null,
  "assets": [
    {
      "assetIndex": 0,
      "type": "image",
      "text": null,
      "url": null,
      "filename": "panel.png",
      "contentType": "image/png",
      "sizeBytes": 204800,
      "uploadState": "uploaded"
    }
  ],
  "upload": {
    "status": "complete",
    "failedAssetIndexes": []
  },
  "processing": {
    "stage": "complete",
    "warnings": [],
    "error": null
  },
  "presentationIds": ["019fd0aa-..."],
  "createdAt": "2026-08-12T12:00:00Z",
  "updatedAt": "2026-08-12T12:01:30Z"
}

output: null for Display-bound Content; a normalised output profile (formats, preset, viewport, colorMode) for targetless Content.

Text and link assets are born uploadState: uploaded; only binary assets go through pendinguploaded (or failed).

Content states

pendingprocessingready, or → failed.

processing.stage tracks progress through: awaiting_upload, fetching_links, summarizing, routing, creating_presentations, complete, failed.

ready does not mean anything is on the panel

A Content reaching ready means the backend persisted its final Presentation IDs. Whether a panel has fetched and confirmed one is a separate question — read the Display's currentPresentationId for what is on the glass.

processing.error carries a stable code, a safe message, the stage it failed at, whether it is retryable, and a nullable assetIndex.


Processing modes

Auto

The analysis stage runs over this Content only, picks a template and params, and chooses one or more accessible compatible Displays. The backend creates one immutable Presentation per chosen Display. If nothing compatible exists, the Content fails with no_compatible_display — bind a Display and resubmit.

confirm → job → analysis (Python/LLM) → callback → routing → Presentations → ready

displayId must not be set for auto. The analysis stage may narrow the Display set via its callback, but each candidate is validated against your ownership.

Manual

The same pipeline, pinned to the Display you named at create time. The requested Display is never substituted. It is re-read from your submission and re-authorised at routing time, so nothing downstream can redirect the render. If it cannot show the content, the Content fails with display_incompatible.

confirm → job → analysis → callback (displayIds ignored) → Presentation on pinned Display → ready

Hardcode

One image, one Display, no AI of any kind.

confirm → job → render task → Presentation on Display → ready

Hardcode geometry — anisotropic stretch

The image is resized straight to 800×480 using Pillow Image.LANCZOS:

  • No letterbox, no centre-crop, no aspect-ratio preservation.
  • An 8:1 panoramic strip is squashed to fill the panel.
  • A 1:1 square is stretched to 5:3.
  • Input dimensions are never a rejection reason. Submit whatever you have; pre-crop to 5:3 yourself if you care about distortion.

The colour PNG preview is retained. The device receives the dithered Floyd–Steinberg raw products exactly as the legacy path produces them.

Hardcode runs no Summary, no Analyze, no LLM, no template stage, and never uses the all-device push path.

Status polling

async function waitForReady(
  contentId: string,
  pat: string,
  intervalMs = 2000,
  maxAttempts = 60
): Promise<Content> {
  for (let i = 0; i < maxAttempts; i++) {
    const res = await fetch(`${BASE}/contents/${contentId}`, {
      headers: { Authorization: `Bearer ${pat}` },
    });
    const content: Content = await res.json();

    if (content.state === "ready" || content.state === "failed") {
      return content;
    }
    await new Promise(r => setTimeout(r, intervalMs));
  }
  throw new Error("Timed out waiting for Content to reach ready");
}

Presentations

A Presentation is immutable through the SDK. v0.1 exposes no create, update, delete, reorder, publish, replay, skip or expire endpoint.

v0.1 has two kinds of Presentation:

  • Display Presentation: displayId is a UUID; contains image; goes through the Display queue and MQTT push path.
  • Targetless Presentation: displayId is null; contains scene (inklet Scene v1) and renditions (PNG list); never touches Display routing. See Targetless Presentations.

GET /presentations/{presentationId}

Display Presentation example:

{
  "id": "019fd0aa-...",
  "displayId": "019fd0cc-...",
  "contentIds": ["019fd100-..."],
  "mode": "hardcode",
  "state": "confirmed",
  "scene": null,
  "renditions": [],
  "image": {
    "url": "https://cdn.iminklet.com/render/.../image.png?...",
    "format": "png",
    "width": 800,
    "height": 480,
    "expiresAt": "2026-08-12T13:15:00Z",
    "updatedAt": "2026-08-12T12:58:11Z"
  },
  "failure": null,
  "createdAt": "2026-08-12T12:01:00Z",
  "updatedAt": "2026-08-12T12:05:00Z"
}

contentIds is ordered and fixed at creation.

Display Presentation state: preparingqueuedpublishedconfirmed, or expired / failed.

?format=png|raw2|raw4 selects the artefact (Display Presentations only). An unknown format is 400 invalid_request.

A Presentation still rendering has a state and no image block — not an image with an empty URL.

A Presentation that is not yours is 404 presentation_not_found.

GET /presentations

GET /api/sdk/v1/presentations?scope=generated&limit=20

scope: generated (targetless, default) / display / all. Returns the standard cursor page envelope.

GET /displays/{displayId}/queue

Cursor-paged, with optional from/to RFC3339 filters. Returns queued entries only — preparing is still rendering and published has already gone out.

  • Display not yours → 403 access_denied
  • No such Display → 404 display_not_found

Queue entries are summary objects with no render metadata:

{
  "items": [
    {
      "id": "019fd0aa-...",
      "displayId": "019fd0cc-...",
      "contentIds": ["019fd100-..."],
      "mode": "hardcode",
      "state": "queued",
      "createdAt": "2026-08-12T12:01:00Z",
      "updatedAt": "2026-08-12T12:01:00Z"
    }
  ],
  "nextCursor": null,
  "hasMore": false
}

GET /displays/{displayId}/current-presentation

Returns the Presentation the Display has confirmed it is showing — never the newest URL handed out.

  • Display not yours → 403 access_denied
  • No such Display → 404 display_not_found

?format=png|raw2|raw4 selects the artefact for the image.url (default png).

{ "presentation": null }

...or the full Presentation object. The presentation envelope is always present, so "nothing is current" is a shape you branch on rather than a missing body.

An offline Display keeps returning its last confirmed Presentation — that is still what is on the glass.

Preview URLs

Signed URLs last approximately 15 minutes. Re-reading a Presentation issues a fresh signature over the same stored image — it never re-renders. Fetch a new URL when the old expires; do not cache beyond expiresAt.


Reads never change anything

Nothing in /api/sdk/v1 promotes a queue entry, publishes to a panel, re-renders, wakes a sleeping Display or changes a sync interval. You cannot accidentally wake a customer's panel by polling a dashboard.

The mutating legacy endpoints (GET /api/devices/{id}/push, which does promote, and POST /api/devices/{id}/current-push) are not reachable from this prefix.


Error reference

Status Code
400 invalid_request
400 invalid_asset
401 authentication_failed
402 payment_required (subscription billing failed)
403 access_denied
403 plan_upgrade_required (Pro feature requested by Free user)
404 display_not_found
404 content_not_found
404 presentation_not_found
409 idempotency_conflict
409 invalid_state
413 asset_too_large
422 display_incompatible
422 no_compatible_display
429 rate_limited (+ Retry-After header)
500 internal_error
503 processing_unavailable

image_dimensions_mismatch does not exist. The Hardcode product override removed it — any input size is accepted.


Security

Rule Detail
PAT is a Bearer token Authorization: Bearer il_pat_... only
No PAT to S3 Use only the presigned fields — never send Authorization to the presigned origin
No JWT A session JWT is rejected with 401 under this prefix
No X-Renewed-Token The sliding-renewal behavior of the session middleware does not apply to PATs
No caller-supplied request ID A supplied X-Request-Id is ignored; use the one on the response
Errors are generic at 500 Internal error text (table names, constraints) is never surfaced

Retry and idempotency summary

Operation Retry mechanism
POST /contents Idempotency-Key (required) — same key+body replays original response
POST /contents/{id}/confirm Inherently retry-safe — atomic claim prevents duplicate jobs
POST /contents/{id}/upload-tickets Idempotent by nature — returns fresh tickets each call
GET endpoints Always safe to retry

Minimal TypeScript types

// ----- Enums -----
type ContentMode   = "auto" | "manual" | "hardcode";
type ContentState  = "pending" | "processing" | "ready" | "failed";
type ProcessStage  =
  | "awaiting_upload" | "fetching_links" | "summarizing"
  | "routing" | "creating_presentations" | "complete" | "failed";
type AssetType     = "text" | "link" | "image" | "file";
type UploadState   = "pending" | "uploaded" | "failed";
type UploadStatus  = "awaiting_upload" | "partial" | "complete";
type PresState     =
  | "preparing" | "queued" | "published" | "confirmed" | "expired" | "failed";

// ----- Assets -----
interface AssetInput {
  type: AssetType;
  text?: string | null;
  url?: string | null;
  filename?: string | null;
  contentType?: string | null;
  sizeBytes?: number | null;
}

interface Asset extends AssetInput {
  assetIndex: number;
  uploadState: UploadState;
}

// ----- Upload ticket -----
interface UploadTicket {
  assetIndex: number;
  url: string;
  fields: Record<string, string>;
  expiresAt: string;
}

// ----- Problem (warning / error) -----
interface Problem {
  code: string;
  message: string;
  stage: string | null;
  retryable: boolean;
  assetIndex: number | null;
}

// ----- Content -----
interface Content {
  id: string;
  mode: ContentMode;
  requestedDisplayId: string | null;
  intent: string | null;
  title: string | null;
  state: ContentState;
  assets: Asset[];
  upload: {
    status: UploadStatus;
    failedAssetIndexes: number[];
  };
  processing: {
    stage: ProcessStage | null;
    warnings: Problem[];
    error: Problem | null;
  };
  presentationIds: string[];
  createdAt: string;
  updatedAt: string;
}

// ----- Capabilities -----
interface Capabilities {
  pixelWidth: 800;
  pixelHeight: 480;
  orientation: "landscape";
  colorMode: "mono";
  supportedImageContentTypes: string[];
  supportedOutputFormats: string[];
}

// ----- Display -----
interface Display {
  id: string;
  hardwareId: string;
  thingName: string;
  name: string;
  nickname: string | null;
  firmware: string | null;
  batteryPercent: number | null;
  online: boolean;
  lastSeenAt: string | null;
  stateUpdatedAt: string | null;
  boundAt: string | null;
  tags: string[];
  syncIntervalMinutes: null;
  nextSyncAt: null;
  currentPresentationId: string | null;
  currentPresentationUpdatedAt: string | null;
  pendingPresentationId: string | null;
  capabilities: Capabilities;
}

// ----- Presentation -----
interface PresentationImage {
  url: string;
  format: string;
  width: number;
  height: number;
  expiresAt: string;
  updatedAt: string;
}

interface Presentation {
  id: string;
  displayId: string;
  contentIds: string[];
  mode: ContentMode;
  state: PresState;
  image: PresentationImage | null;
  failure: Problem | null;
  createdAt: string;
  updatedAt: string;
}

// ----- API responses -----
interface PagedResponse<T> {
  items: T[];
  nextCursor: string | null;
  hasMore: boolean;
}

interface CreateContentResponse {
  content: Content;
  uploadTickets: UploadTicket[];
}

interface ApiError {
  error: {
    code: string;
    message: string;
    requestId: string;
    details?: Record<string, unknown>;
  };
}

SDK implementation checklist

Use this list to verify a generated or hand-written SDK covers the full contract. Nothing here depends on the legacy /api/* routes.

Auth

  • [ ] Send Authorization: Bearer il_pat_... on every request
  • [ ] Reject / never send a session JWT
  • [ ] Never send the PAT to the presigned S3 origin
  • [ ] Expose X-Request-Id from every response for error reporting

Displays

  • [ ] GET /displays with cursor + limit pagination
  • [ ] GET /displays/{id} — handle 403 access_denied distinct from 404
  • [ ] Read capabilities from the Display, do not hard-code 800×480

Contents

  • [ ] Generate a unique Idempotency-Key per logical POST /contents attempt
  • [ ] Support mode: auto | manual | hardcode with correct displayId rules
  • [ ] Support all four asset types: text, link, image, file
  • [ ] Upload each binary asset to ticket.url with ticket.fields — no Authorization
  • [ ] Handle partial confirm (upload.failedAssetIndexes) and retry only failed indexes
  • [ ] Refresh expired tickets via POST /contents/{id}/upload-tickets before re-uploading
  • [ ] Poll GET /contents/{id} for state change after confirm
  • [ ] GET /contents with state, mode, cursor, limit filters

Processing

  • [ ] Handle 503 processing_unavailable with retry logic
  • [ ] Record processing.error.code for terminal failures
  • [ ] Understand that ready ≠ Presentation confirmed on panel

Presentations

  • [ ] GET /presentations/{id} — handle image: null while preparing; unauthorized → 404 not 403
  • [ ] GET /displays/{id}/queue — cursor paged, queued entries only, from/to filters; 403 if not yours
  • [ ] GET /displays/{id}/current-presentationformat param (default png); handle { "presentation": null }; 403 if not yours
  • [ ] Refresh signed preview URL when image.expiresAt passes (re-reading re-signs same object)

Errors

  • [ ] Branch on error.code, not error.message
  • [ ] Surface error.requestId in logs and support flows
  • [ ] Handle 429 rate_limited with Retry-After header

Out of scope for v0.1

Projects, service keys and scopes; pairing, unbind, Wi-Fi, firmware and sync mutation; Presentation and queue mutation; scheduling and priority; arbitrary HTML/CSS; webhooks; waking sleeping Displays; changing legacy Portal/iOS/device APIs.