跳转至

Inklet SDK v0.1

/api/sdk/v1 是服务端 JS/TS SDK 对接的接口面。它是一层增量 facade:底层读取的仍是 Portal、iOS App 和设备共用的同一批数据,只是用三个自己的名词重新组织。

SDK 名词 含义
Display 你拥有的一块屏
Content 一次提交:文本、链接、图片、文件,或它们的组合
Presentation 一份不可变的渲染结果:投向某一块 Display(面向 Display),或独立生成的 Scene + PNG 渲染图(targetless)

Targetless Presentation(无设备呈现)

在 v0.1 中,POST /contents 支持可选的 output 字段。加入此字段即可在不绑定任何显示屏的情况下生成可视化内容(inklet Scene v1 + PNG rendition)。详见 Targetless Presentations

Base URL

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

一次推送请求只创建一个 Content,而 Content 不是一个显示任务。 它可能产出多个 Presentation——每到达一块 Display 就有一个——每个一经创建即不可变。


快速开始

五步把一张图推到指定的 Display。

第 1 步 —— 获取 Personal Access Token

/api/personal-access-tokens 创建(参见 个人访问令牌),形如 il_pat_...

第 2 步 —— 列出你的 Display

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

第 3 步 —— 创建 Content 并获取上传票据

Hardcode 模式:一张 PNG/JPEG 直接推到某块 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]

第 4 步 —— 把二进制 asset 上传到 S3

切勿把 PAT 发往预签名地址

只使用票据里的 fields,不要附加 Authorization 头。

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

票据在 ticket.expiresAt 过期。若已过期,调用 POST /contents/{contentId}/upload-tickets 重新签发。

第 5 步 —— 确认上传并轮询直到 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);
}

state 到达 readypresentationIds 即已填充。屏幕会按自己的节奏取图并确认——ready 表示后端已把渲染排上队,不代表屏幕已经显示出来了。


认证

只接受用户级 Personal Access Token:

Authorization: Bearer il_pat_...

有两点与后端其余部分不同,且是刻意为之:

  • Session JWT 在这里会被拒绝。 它在别处都能认证,但在这个前缀下一律 401
  • 永不下发 X-Renewed-Token 滑动续签不适用于 PAT。

所有失败态——缺 header、scheme 错误、传了 JWT、未知 / 过期 / 已撤销的 PAT——都返回完全相同401 authentication_failed,因此无法用响应差异去探测某个 token 是否存在。

PAT 的生命周期管理(POST / DELETE /api/personal-access-tokens)不在本 facade 内。


通用约定

  • 请求与响应均为 JSON;UUID 用字符串;时间戳为 RFC3339 UTC。
  • 可选值显式为 null;稳定数组为 [],不会是 null
  • 未知的请求字段会被忽略;未知的枚举值返回 400 invalid_request

请求 ID

每个响应都带 X-Request-Id,每个错误 body 的 requestId 里也会复述同一个值。报障时请附上它。

该 ID 由服务端生成。客户端传入的 X-Request-Id 会被忽略而不是回显:回显等于开放了 header 注入的口子。请用响应里返回的那个 ID。

错误信封

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

请依据 code 分支处理。message 的措辞不属于契约,可能变动。details 为空时会被省略。

幂等

POST /contents 必须携带 Idempotency-Key 请求头,8–128 个可打印 ASCII 字符。

  • 作用域为 用户 + method + 路由;请求 body 用 SHA-256 计算指纹。
  • 结果保留 24 小时
  • 窗口内同 key 同 body → 原样重放原始响应,包括原始的上传票据。副作用不会执行第二次。
  • 同 key 不同 body → 409 idempotency_conflict
  • 首个请求仍在进行中时同 key 再次到达 → 409 invalid_state(结果尚不可重放)。
  • 超过 24 小时后,该 key 视同从未出现过。

请求被拒绝时会释放该 key,因此你可以改好 body 后用同一个 key 重试。

重放不会创建新的 Content 或新的处理任务。若重放拿到的票据已过期,请通过 POST /contents/{contentId}/upload-tickets 重新签发。

分页

基于 cursor,按 (createdAt, id) 降序,顺序确定。limit 默认 20,最大 50。

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

非法的 cursor 或超范围的 limit 返回 400 invalid_request——而不是静默退回第一页。请把 cursor 当作不透明字符串,原样回传。


Display

GET /displays

分页列出你拥有的 Display。

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

空列表与后端故障是两种不同的答案:一块屏都没有时返回 200 {"items": []},故障时返回 500。客户端绝不应因为后端不可达就渲染出一个空面板。

GET /displays/{displayId}

curl -H "Authorization: Bearer $PAT" \
  "https://dev.iminklet.com/api/sdk/v1/displays/$DISPLAY_ID"
  • Display 存在但不属于你 → 403 access_denied
  • 不存在 → 404 display_not_found
  • displayId 不是 UUID → 400 invalid_request

Display 模型

{
  "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 回退为 thingName——始终非空。

v0.1 中 syncIntervalMinutesnextSyncAt 恒为 null

设备按固件侧的节奏轮询,后端既不存储也不控制这个周期。这两个字段先行存在,是为了将来接入数据源时不构成破坏性变更。请不要依据它们做任何调度。

Capabilities

v0.1 中所有 Display 都返回同一份固定画像:800×480、横向(landscape)、单色(mono)。capabilities 由统一的 provider 接口层解析,将来改为按 SKU 区分时这段结构也不会变。请始终从 Display 读取 capabilities,不要硬编码尺寸。

current 与 pending

这是两个彼此独立的问题:

字段 含义
currentPresentationId 屏幕已确认正在显示的那一个——玻璃上真实呈现的内容
pendingPresentationId 已发布给这块屏、但尚未被确认的那一个。确认后变为 null

"current" 绝不等于"我们最后发出去的那份"。未确认的 Presentation 可能从未被取走——屏幕当时也许在休眠。离线的 Display 会保留这两个引用、capabilities 和最后已知状态。


Content

POST /contents

必须携带 Idempotency-Key。创建 Content,并为每个二进制 asset 返回一张预签名 S3 POST 票据。文本与链接 asset 内联携带内容,不产生票据。

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
    }]
  }'

各 mode 的规则

Mode displayId output Asset
auto(面向 Display) 不得指定 省略 一个或多个,类型不限
auto(targetless) 禁止(null 或省略) 必须有 一个或多个,类型不限
manual 必填,且必须是你可访问的 不得有 一个或多个,类型不限
hardcode(面向 Display) 必填,且必须是你可访问的 省略 恰好一张 PNG 或 JPEG
hardcode(targetless) 禁止(null 或省略) 必须有 恰好一张 PNG 或 JPEG

output 字段的存在是 targetless 分流的唯一依据。详见 Targetless Presentations

响应 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 是你在 assets 数组里的原始索引——它会贯穿票据、失败报告和落库的 Content,所以"索引 2 失败"指的就是你提交的第 3 个 asset。

Asset 类型

类型 必填字段 允许的取值
text text(不能是纯空白)
link url(绝对 HTTP/S,不含凭据)
image filenamecontentTypesizeBytes image/pngimage/jpegimage/gifimage/webpimage/svg+xml
file filenamecontentTypesizeBytes application/pdftext/plaintext/markdownapplication/json

单个二进制 asset 上限 10 MiB(超出为 413 asset_too_large),单次请求最多 50 个 asset。无法识别的 contentType 返回 400 invalid_asset;未知的 type 返回 400 invalid_request。声明的 type 必须与 contentType 一致——把 PDF 声明为 image 会被拒绝。

把二进制 asset 上传到 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}`);
}

若票据在上传完成前过期,请用 POST /contents/{contentId}/upload-tickets 重新签发(见下)——重放 POST /contents 拿回的仍是那张原始的、已过期的票据。

POST /contents/{contentId}/confirm

校验每一个二进制 asset(HeadObject),全部到位则恰好投递一个处理任务。

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"
结果 state upload.status
全部 asset 到位 processing complete
部分 asset 缺失 pending partial——见 upload.failedAssetIndexes
已确认过 保持不变 ——返回已有 Content,不产生重复任务

confirm 本身就是可安全重试的,无需 Idempotency-Key。并发的 confirm 通过数据库原子 claim 保证只投递一个任务。

POST /contents/{contentId}/upload-tickets

为失败或票据已过期的二进制 asset 重新签发票据。

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();
  • 文本与链接索引返回 400 invalid_request——它们没有上传这一步。
  • 已经开始处理的 Content 返回 409 invalid_state

GET /contents/{contentId}

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

不属于你的 Content 返回 404 content_not_found。与 Display 不同,Content 没有 403——Content ID 不可被探测。

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();

过滤条件:statependingprocessingreadyfailed)、modeautomanualhardcode)。任一项传入未知值都返回 400 invalid_request,而不是一个空页——拼错应该被指出来,而不是被悄悄吞掉。

Content 模型

{
  "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 字段:面向 Display 的 Content 为 null;targetless Content 为归一化的 output profile(含 formatspresetviewportcolorMode)。

文本与链接 asset 生来就是 uploadState: uploaded;只有二进制 asset 会经历 pendinguploaded(或 failed)。

Content 状态

pendingprocessingready,或 → failed

processing.stage 反映进度:awaiting_uploadfetching_linkssummarizingroutingcreating_presentationscompletefailed

ready 不代表屏上已经出现了东西

Content 到达 ready 只意味着后端已把最终的 Presentation ID 落库。屏幕是否已取走并确认某一个,是另一个问题——玻璃上真实呈现的内容请读 Display 的 currentPresentationId

processing.error 带有稳定的 code、安全的 message、失败所处的 stage、是否 retryable,以及可为 null 的 assetIndex


处理模式

Auto

分析阶段只针对这一个 Content 运行,挑选模板与参数,并选出一块或多块可访问且兼容的 Display。后端为每块选中的 Display 创建一个不可变的 Presentation。若找不到兼容的目标,Content 以 no_compatible_display 失败——绑定一块屏后重新提交即可。

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

auto 不得设置 displayId。分析阶段可以通过回调收窄 Display 集合,但每个候选都会再对照你的归属关系做校验。

Manual

流程相同,但锁定到你在创建时指定的那块 Display。指定的 Display 绝不会被替换。 它会在路由阶段从你的提交中重新读取并重新鉴权,因此下游任何环节都无法改变渲染落点。若该屏无法显示该内容,Content 以 display_incompatible 失败。

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

Hardcode

一张图、一块屏,不经过任何 AI。

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

Hardcode 的缩放规则 —— 各向异性拉伸

图片会用 Pillow Image.LANCZOS 直接缩放到 800×480

  • 不做 letterbox、不裁剪、不保持宽高比。
  • 一张 8:1 的全景条会被压扁以铺满整屏。
  • 一张 1:1 的正方形会被拉成 5:3。
  • 输入尺寸永远不是拒绝的理由。 有什么图就传什么图;在意变形的话请自行先裁成 5:3。

彩色 PNG 预览会保留。设备收到的仍是与旧路径完全一致的 Floyd–Steinberg 抖动 raw 产物。

Hardcode 不跑 Summary、不跑 Analyze、不调 LLM、不经过模板阶段,也绝不使用全设备推送路径。

状态轮询

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");
}

Presentation

Presentation 通过 SDK 是不可变的。v0.1 没有提供任何 create、update、delete、reorder、publish、replay、skip 或 expire 接口。

v0.1 有两类 Presentation:

  • 面向 DisplaydisplayId 为 UUID,包含 image 字段,对应 Display 的渲染队列与 MQTT 推送链路。
  • Targetless(无设备)displayIdnull,包含 scene(inklet Scene v1)和 renditions(PNG 渲染图列表),不涉及 Display 路由。详见 Targetless Presentations

GET /presentations/{presentationId}

面向 Display 的 Presentation 响应示例:

{
  "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有序的,且该顺序在创建时即已固定。

面向 Display 的 statepreparingqueuedpublishedconfirmed,或 expired / failed

?format=png|raw2|raw4 选择产物(仅面向 Display 的 Presentation 支持)。

仍在渲染中的 Presentation 有 state,但没有 image——而不是一个 URL 为空的 image。

不属于你的 Presentation 返回 404 presentation_not_found

GET /presentations

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

scope 可选值:generated(targetless,默认)/ display / all。返回标准 cursor 分页信封。

GET /displays/{displayId}/queue

基于 cursor 分页,可选 from / to(RFC3339)过滤。只返回排队中的条目——preparing 还在渲染,published 已经发出。

  • Display 不属于你 → 403 access_denied
  • Display 不存在 → 404 display_not_found

队列条目是摘要对象,无渲染元数据:

{
  "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

返回 Display 已确认正在显示的 Presentation——绝不是最新发出的那个 URL。

  • Display 不属于你 → 403 access_denied
  • Display 不存在 → 404 display_not_found

?format=png|raw2|raw4 选择 image.url 所指向的产物(默认 png)。

{ "presentation": null }

或者返回完整的 Presentation 对象。presentation 这层信封始终存在,因此"当前没有内容"是一个可以直接判断的结构,而不是缺失的 body。

离线的 Display 继续返回其最后一次确认的 Presentation——那仍然是屏幕上的实际内容。

预览 URL

签名 URL 有效期约 15 分钟。重新读取 Presentation 会对同一份已存储的图片重新签名——绝不会重新渲染。过期后再取一次新的即可;请不要缓存超过 expiresAt 的 URL。


读操作不会改变任何状态

/api/sdk/v1 中没有任何接口会提升队列条目、向屏幕发布、重新渲染、唤醒休眠的 Display 或修改同步周期。你不可能因为轮询一个面板就把客户的屏幕唤醒。

会产生写入的旧接口(GET /api/devices/{id}/push——它确实会提升状态——以及 POST /api/devices/{id}/current-push)在这个前缀下不可达。


错误码速查

状态码 Code
400 invalid_request
400 invalid_asset
401 authentication_failed
402 payment_required(订阅扣款失败)
403 access_denied
403 plan_upgrade_required(Free 用户请求 Pro 功能)
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 响应头)
500 internal_error
503 processing_unavailable

image_dimensions_mismatch 不存在。Hardcode 的产品决策已将其移除——任何输入尺寸都会被接受。


安全

规则 说明
PAT 是 Bearer token 只接受 Authorization: Bearer il_pat_...
不要把 PAT 发给 S3 只使用预签名的 fields——绝不向预签名地址发送 Authorization
不接受 JWT Session JWT 在这个前缀下一律 401
X-Renewed-Token Session 中间件的滑动续签不适用于 PAT
不接受调用方传入的请求 ID 传入的 X-Request-Id 会被忽略;请用响应里返回的那个
500 的错误信息是通用的 内部错误文本(表名、约束名)绝不外泄

重试与幂等一览

操作 重试机制
POST /contents Idempotency-Key(必填)——同 key 同 body 原样重放原始响应
POST /contents/{id}/confirm 天然可安全重试——原子 claim 防止重复任务
POST /contents/{id}/upload-tickets 本身幂等——每次调用返回新票据
GET 类接口 永远可安全重试

最小 TypeScript 类型定义

// ----- 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 实现自检清单

用这份清单核对手写或生成的 SDK 是否覆盖了完整契约。其中没有任何一项依赖旧的 /api/* 路由。

认证

  • [ ] 每个请求都带 Authorization: Bearer il_pat_...
  • [ ] 拒绝 / 永不发送 Session JWT
  • [ ] 绝不把 PAT 发往预签名 S3 地址
  • [ ] 从每个响应中暴露 X-Request-Id,供报障使用

Display

  • [ ] GET /displays 支持 cursor + limit 分页
  • [ ] GET /displays/{id} —— 区分处理 403 access_denied404
  • [ ] 从 Display 读取 capabilities,不要硬编码 800×480

Content

  • [ ] 每次逻辑上的 POST /contents 尝试生成唯一的 Idempotency-Key
  • [ ] 支持 mode: auto | manual | hardcode 及其各自的 displayId 规则
  • [ ] 支持全部四种 asset 类型:text、link、image、file
  • [ ] 用 ticket.fields 把每个二进制 asset 上传到 ticket.url——不带 Authorization
  • [ ] 处理部分确认(upload.failedAssetIndexes),只重试失败的索引
  • [ ] 重传前先用 POST /contents/{id}/upload-tickets 刷新过期票据
  • [ ] confirm 之后轮询 GET /contents/{id} 观察 state 变化
  • [ ] GET /contents 支持 statemode、cursor、limit 过滤

处理

  • [ ] 对 503 processing_unavailable 实现重试逻辑
  • [ ] 记录终态失败的 processing.error.code
  • [ ] 理解 ready ≠ 屏幕已确认该 Presentation

Presentation

  • [ ] GET /presentations/{id} —— 处理渲染中时 image: null 的情况;非所属 Display 的 Presentation 返回 404(非 403
  • [ ] GET /displays/{id}/queue —— cursor 分页,只含 queued 条目;不属于你时返回 403
  • [ ] GET /displays/{id}/current-presentation —— format 参数(默认 png);处理 { "presentation": null };不属于你时返回 403
  • [ ] image.expiresAt 过期后刷新签名预览 URL(重读会重签名,不会重新渲染)

错误

  • [ ] 依据 error.code 分支,而不是 error.message
  • [ ] 在日志与客服流程中暴露 error.requestId
  • [ ] 依据 Retry-After 响应头处理 429 rate_limited

v0.1 不在范围内的能力

Project、Service Key 与 Scope;配对、解绑、Wi-Fi、固件与同步周期的修改;Presentation 与队列的修改;调度与优先级;任意 HTML/CSS;Webhook;唤醒休眠的 Display;修改旧的 Portal / iOS / 设备接口。