Pulse Relay
网站接入 API · v1

网站接入文档

网站做一个「允许连接」页面和三个接口,状态变化时向 Pulse Relay 推送中转发一个签名的 HTTPS 请求,用户 iPhone 上的灵动岛和锁屏就会实时更新。不需要苹果开发者账号,也不需要 APNs 密钥。想先看效果,可以去试一试。

接入流程

  1. 用户在 Pulse Relay App 里输入你的网站地址并点「连接」。
  2. App 读取 /.well-known/pulse-relay.json,确认你的网站支持接入。
  3. App 向推送中转申请一把只属于你的网站、只能推送给这台手机的钥匙,然后在系统的安全登录窗口(和 Safari 共用登录)里打开你的 authorize 页面,钥匙放在网址 # 后面。
  4. 用户在这个窗口里登录(Safari 里登录过就不用再登),点「允许」。你的页面把钥匙交给自己的服务器,和当前登录用户绑定保存,然后跳回 App。
  5. 之后这个用户的事情有变化时,你用这把钥匙向推送中转发请求,灵动岛随之更新。
  6. 用户在 App 里断开或删除 App 后,钥匙失效;你调用中转会收到 401 / 403 / 410,删掉保存的钥匙即可。

Pulse Relay App 看不到安全登录窗口里的页面和用户的登录信息;用户之后在 App「网页」页里浏览你的网站时,App 也不会读取页面内容(iOS 的 App-Bound Domains 限制)。

网站要做的事

1. GET /.well-known/pulse-relay.json

公开,不需要登录。必须是 HTTPS,Content-Type: application/json。

{
  "version": 1,
  "name": "Example Shop",
  "authorize": "/pulse-relay/authorize",
  "connect": "/api/pulse-relay/connect",
  "open": "/orders/{ref}"
}
字段必填说明
version是固定为 1
name是显示在灵动岛和锁屏上的网站名,不超过 20 个字
authorize是下面 2 的「允许连接」页面,同源路径,以 / 开头
connect是下面 3、4 的接口地址,同源路径,以 / 开头
open否用户点灵动岛时,App 打开的同源路径,{ref} 会替换成这件事的 ref

2. {authorize} 页面:请用户允许连接

App 打开的地址形如 https://你的网站/pulse-relay/authorize#relay=…&deviceId=…&sourceId=…&pushKey=…&deviceName=…&state=…。

  • 连接信息在 # 后面,不会发给服务器,也就不会进访问日志。页面加载后用 history.replaceState 把它从地址栏去掉。
  • 未登录时按平时的方式登录,登录后回到本页并保留 # 后面的内容(例如登录前存进 sessionStorage)。
  • 显示网站名和「允许 / 不允许」。用户点「允许」后,用同源 fetch 把 relay、deviceId、sourceId、pushKey、deviceName 发给 connect 接口。
  • 完成后跳回 App,地址固定以 pulserelay://site-connected? 开头(不要从参数里取):成功 state=<原样带回>&status=ok,可选 &next=<同源路径>(App 连接后在网页页打开);不允许 status=denied;出错 status=error&code=<HTTP 状态码>。
  • 没收到 status=ok 时,App 会在中转撤销这把钥匙。

安全登录窗口用的是 Safari 的 Cookie,和 App「网页」页不共用。同一个账号一般没有问题;匿名访客的网站(像本站演示)可以用 next 带一个一次性领取码,让网页页认出同一个访客。可以参考本站的 authorize 页面。

3. POST {connect}:保存推送钥匙

由 authorize 页面用同源 fetch 调用(带上你网站自己的登录 Cookie),并带请求头 X-Pulse-Relay: 1。

{
  "relay": "https://relay.crsyounen.com",
  "deviceId": "8d3f…",
  "sourceId": "s_7Kq…",
  "pushKey": "base64url 编码的 32 字节",
  "deviceName": "iPhone"
}
  • 按你网站平时的登录态确认是哪个用户;未登录返回 401。
  • 必须检查 X-Pulse-Relay: 1 请求头,并只接受 application/json,防止别的网站用表单伪造请求。
  • 只接受你认识的 relay 地址,不要把钥匙交给别的地址。
  • 一个用户可以有多台设备:以 deviceId + sourceId 为键保存。同一 deviceId 再次连接时,用新的替换旧的。
  • pushKey 是密钥:只存在服务端,不要写进日志,不要发给浏览器。
  • 成功返回 200 和 {"ok":true}。

4. DELETE {connect}:删除推送钥匙

同样带 X-Pulse-Relay: 1,请求体 {"deviceId":"…","sourceId":"…"}。只删除当前用户自己的那条,重复调用也返回 200。

发推送

POST https://relay.crsyounen.com/v1/devices/{deviceId}/push
Content-Type: application/json
X-Pulse-Source: {sourceId}
X-Pulse-Auth: v2 <unix秒> <requestId> <签名>

签名 = base64url( HMAC-SHA256( base64url解码(pushKey), "POST\n" + 路径 + "\n" + unix秒 + "\n" + requestId + "\n" + sha256十六进制(请求体) ) )。路径是 /v1/devices/{deviceId}/push(不含域名);requestId 每次新生成一个 UUID;服务器时间与中转相差不能超过 5 分钟。

{
  "ref": "order-1024",
  "event": "update",
  "timestamp": 1793000000,
  "contentState": { "title": "订单 #1024", "summary": "骑手已取餐", "status": "running", "revision": 1793000000123 },
  "alert": { "body": "骑手已取餐" }
}
字段说明
ref你这边这件事的标识,1–64 个字符,只能用字母、数字和 ._-。同一 ref 在手机上只有一个实时活动
eventstart 新开一个实时活动(同一 ref 原来的会先被关掉);update 更新;end 结束
timestamp当前 unix 秒
contentState显示内容,见模板
alert可选。带上时灵动岛会弹出并亮屏(静音时震动)。只有 body(不超过 60 字),标题固定为你的网站名。start 必须带(没带时中转用 title 补上)
staleDate可选,unix 秒。过了这个时间仍没有更新,系统会把它标成过期
dismissalDate仅 end,可选,unix 秒。结束后在锁屏上保留到这个时间
revision 必须严格递增(同一 ref),建议直接用毫秒时间戳。小于等于上一次的会被拒绝(409),防止旧消息晚到覆盖新状态。

四种模板

所有模板都要 title(不超过 60 字)、summary(可为空,不超过 120 字)、status、revision。template 不写就是「状态」模板。其余字段按模板选用,不需要的不要发。

running 进行中 waiting 等待中 done 已完成 error 出错 thinking 也接受

状态 status

锁屏:状态模板 灵动岛:状态模板

大字显示状态词,下面一行是标题和摘要。

{ "title": "导出报表", "summary": "正在生成 PDF",
  "status": "running", "revision": 1793000000123 }

进度 progress

锁屏:进度模板 灵动岛:进度模板

大字百分比和进度条。progress 为 0–1;可选 endsAt(预计完成,unix 秒),右侧剩余时间由系统自己倒数。紧凑灵动岛显示「42%」,最小灵动岛显示进度环。建议每变化 5% 或每 10 秒推一次。

{ "template": "progress", "progress": 0.42, "endsAt": 1793000300,
  "title": "上传 4 个文件", "summary": "已传 1.2 GB",
  "status": "running", "revision": 1793000000123 }

步骤 steps

锁屏:步骤模板 灵动岛:步骤模板

大字是当前步骤名,下面是分段条。step 从 1 开始;steps 最多 8 个、每个不超过 16 字;不想给名字时只给 stepCount(1–8),大字显示「3 / 4」。紧凑灵动岛显示「3/4」。

{ "template": "steps", "step": 3,
  "steps": ["已下单", "备餐中", "配送中", "已送达"],
  "title": "订单 #1024", "summary": "骑手已取餐",
  "status": "running", "revision": 1793000000123 }

倒计时 countdown

锁屏:倒计时模板 灵动岛:倒计时模板

大字是系统自己走秒的倒计时,期间不需要推送。endsAt 必填;给了 startsAt 就同时显示随时间前进的进度条。到点后显示 0:00,请在到点时推一次 update 或 end。

{ "template": "countdown", "startsAt": 1793000000, "endsAt": 1793000900,
  "title": "产品评审会", "summary": "3 号会议室",
  "status": "waiting", "revision": 1793000000123 }
  • 模板字段不合法时(例如 progress 不在 0–1、steps 超过 8 个),中转返回 400;手机端遇到不完整的模板字段会退回「状态」模板。
  • 中转会在 contentState 里填上你的网站名,并去掉只供 App 自己使用的字段。
  • 一台手机同时显示的实时活动有上限(系统约 5 个),事情结束后请发 end。实时活动最长显示 8 小时,更长的事情请中途 end 后重新 start。

返回码与频率

状态含义你该做什么
200已交给苹果—
400请求体不合格,响应里有 error修正
401 / 403钥匙无效或已被用户断开删除保存的钥匙,不再重试
404手机上没有这个 ref 的实时活动(被划掉或从没开始)需要的话改发 start
409revision 没有递增用更大的 revision
410这台手机已不可用删除保存的钥匙
429太频繁按 Retry-After 等待后再发
5xx中转或苹果暂时出错稍后重试,带退避
限制(每个网站、每台手机)上限超过时
同一 ref 的推送每秒 1 次429
全部推送每小时 600 次429
start每小时 4 次;同一 ref 30 秒 1 次429
带 alert 的 update / end同一 ref 30 秒 1 次去掉提醒照常发送,响应里 "alertDropped": true
start 会在 App 不在前台时由 iOS 新建实时活动,而 iOS 对每台手机的远程开始次数有限额,用完后一段时间内谁都开不了(包括其他网站和电脑端)。只在一件新事情开始时发 start,其余用 update。

示例代码

import crypto from 'node:crypto';

// saved = 用户连接时你保存的 { relay, deviceId, sourceId, pushKey }
export async function pushActivity(saved, body) {
  const path = `/v1/devices/${saved.deviceId}/push`;
  const raw = JSON.stringify({ timestamp: Math.floor(Date.now() / 1000), ...body });
  const seconds = String(Math.floor(Date.now() / 1000));
  const requestId = crypto.randomUUID();
  const digest = crypto.createHash('sha256').update(raw).digest('hex');
  const signature = crypto.createHmac('sha256', Buffer.from(saved.pushKey, 'base64url'))
    .update(`POST\n${path}\n${seconds}\n${requestId}\n${digest}`).digest('base64url');
  const response = await fetch(saved.relay.replace(/\/$/, '') + path, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Pulse-Source': saved.sourceId,
      'X-Pulse-Auth': `v2 ${seconds} ${requestId} ${signature}`,
    },
    body: raw,
  });
  return response.status; // 401/403/410:删除 saved
}

// 例:订单进入第 3 步,并提醒用户
await pushActivity(saved, {
  ref: 'order-1024', event: 'update', alert: { body: '骑手已取餐' },
  contentState: { template: 'steps', step: 3, steps: ['已下单', '备餐中', '配送中', '已送达'],
                  title: '订单 #1024', summary: '骑手已取餐', status: 'running', revision: Date.now() },
});

给 Agent 和自动化工具

让 Claude Code、Codex 等 Agent 帮你接入时,把下面的地址交给它即可:完整接口说明是纯 Markdown,便于直接阅读。

完整说明

允许连接页面、三个接口、签名、模板、返回码和示例代码。

/pulse-relay-api.md

索引

按 llms.txt 约定的简短索引。

/llms.txt

接入示例

本站自己就是一个接入了的网站:/.well-known/pulse-relay.json 可以直接参考。

查看

隐私和安全

  • 灵动岛和锁屏在手机锁定时也能被旁人看到:标题和摘要不要放敏感信息。
  • 推送内容经过 Pulse Relay 推送中转和苹果服务器,中转不保存显示内容。
  • 每把钥匙只能推送给一台手机、只能改你自己网站的实时活动,用户随时可以在 App 里断开。