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:
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-Tokenis 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[], nevernull. - 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.
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.
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}¶
- Display exists but is not yours →
403 access_denied - No such Display →
404 display_not_found displayIdis 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.
| 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 pending → uploaded (or failed).
Content states¶
pending → processing → ready, 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.
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.
Hardcode¶
One image, one Display, no AI of any kind.
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:
displayIdis a UUID; containsimage; goes through the Display queue and MQTT push path. - Targetless Presentation:
displayIdisnull; containsscene(inklet Scene v1) andrenditions(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: preparing → queued → published → confirmed, 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¶
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).
...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-Idfrom every response for error reporting
Displays¶
- [ ]
GET /displayswith cursor + limit pagination - [ ]
GET /displays/{id}— handle403 access_denieddistinct from404 - [ ] Read
capabilitiesfrom the Display, do not hard-code 800×480
Contents¶
- [ ] Generate a unique
Idempotency-Keyper logicalPOST /contentsattempt - [ ] Support
mode: auto | manual | hardcodewith correctdisplayIdrules - [ ] Support all four asset types: text, link, image, file
- [ ] Upload each binary asset to
ticket.urlwithticket.fields— noAuthorization - [ ] Handle partial confirm (
upload.failedAssetIndexes) and retry only failed indexes - [ ] Refresh expired tickets via
POST /contents/{id}/upload-ticketsbefore re-uploading - [ ] Poll
GET /contents/{id}forstatechange after confirm - [ ]
GET /contentswithstate,mode, cursor, limit filters
Processing¶
- [ ] Handle
503 processing_unavailablewith retry logic - [ ] Record
processing.error.codefor terminal failures - [ ] Understand that
ready≠ Presentation confirmed on panel
Presentations¶
- [ ]
GET /presentations/{id}— handleimage: nullwhile preparing; unauthorized →404not403 - [ ]
GET /displays/{id}/queue— cursor paged, queued entries only,from/tofilters;403if not yours - [ ]
GET /displays/{id}/current-presentation—formatparam (defaultpng); handle{ "presentation": null };403if not yours - [ ] Refresh signed preview URL when
image.expiresAtpasses (re-reading re-signs same object)
Errors¶
- [ ] Branch on
error.code, noterror.message - [ ] Surface
error.requestIdin logs and support flows - [ ] Handle
429 rate_limitedwithRetry-Afterheader
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.