网站接入文档
网站做一个「允许连接」页面和三个接口,状态变化时向 Pulse Relay 推送中转发一个签名的 HTTPS 请求,用户 iPhone 上的灵动岛和锁屏就会实时更新。不需要苹果开发者账号,也不需要 APNs 密钥。想先看效果,可以去试一试。
接入流程
- 用户在 Pulse Relay App 里输入你的网站地址并点「连接」。
- App 读取
/.well-known/pulse-relay.json,确认你的网站支持接入。 - App 向推送中转申请一把只属于你的网站、只能推送给这台手机的钥匙,然后在系统的安全登录窗口(和 Safari 共用登录)里打开你的
authorize页面,钥匙放在网址#后面。 - 用户在这个窗口里登录(Safari 里登录过就不用再登),点「允许」。你的页面把钥匙交给自己的服务器,和当前登录用户绑定保存,然后跳回 App。
- 之后这个用户的事情有变化时,你用这把钥匙向推送中转发请求,灵动岛随之更新。
- 用户在 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 在手机上只有一个实时活动 |
event | start 新开一个实时活动(同一 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 |
409 | revision 没有递增 | 用更大的 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() },
});
import base64, hashlib, hmac, json, time, uuid, urllib.request, urllib.error
def b64url_decode(text):
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def push_activity(saved, body):
path = f"/v1/devices/{saved['deviceId']}/push"
raw = json.dumps({"timestamp": int(time.time()), **body}, ensure_ascii=False).encode()
seconds, request_id = str(int(time.time())), str(uuid.uuid4())
message = f"POST\n{path}\n{seconds}\n{request_id}\n{hashlib.sha256(raw).hexdigest()}".encode()
signature = base64.urlsafe_b64encode(
hmac.new(b64url_decode(saved["pushKey"]), message, hashlib.sha256).digest()).rstrip(b"=").decode()
request = urllib.request.Request(saved["relay"].rstrip("/") + path, data=raw, method="POST", headers={
"Content-Type": "application/json",
"X-Pulse-Source": saved["sourceId"],
"X-Pulse-Auth": f"v2 {seconds} {request_id} {signature}",
})
try:
with urllib.request.urlopen(request, timeout=10) as response:
return response.status
except urllib.error.HTTPError as error:
return error.code # 401/403/410:删除 saved
# 例:上传进度 42%,预计 5 分钟后完成
push_activity(saved, {
"ref": "upload-77", "event": "update",
"contentState": {"template": "progress", "progress": 0.42, "endsAt": int(time.time()) + 300,
"title": "上传 4 个文件", "summary": "已传 1.2 GB", "status": "running",
"revision": int(time.time() * 1000)},
})
RELAY=https://relay.crsyounen.com DEVICE=… SOURCE=… KEY=…
BODY='{"ref":"demo","event":"update","timestamp":'$(date +%s)',"contentState":{"title":"测试","summary":"","status":"running","revision":'$(date +%s)'000'}}'
P=/v1/devices/$DEVICE/push; T=$(date +%s); R=$(uuidgen | tr A-Z a-z)
D=$(printf %s "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')
PAD=$(printf %s "$KEY" | awk '{ n = (4 - length($0) % 4) % 4; s = $0; for (i = 0; i < n; i++) s = s "="; print s }')
HEXKEY=$(printf %s "$PAD" | tr '_-' '/+' | base64 -d | xxd -p -c 256)
S=$(printf 'POST\n%s\n%s\n%s\n%s' "$P" "$T" "$R" "$D" | openssl dgst -sha256 -mac HMAC -macopt hexkey:$HEXKEY -binary | base64 | tr '+/' '-_' | tr -d '=')
curl -s -w '\n%{http_code}\n' "$RELAY$P" -H 'Content-Type: application/json' -H "X-Pulse-Source: $SOURCE" -H "X-Pulse-Auth: v2 $T $R $S" -d "$BODY"
给 Agent 和自动化工具
让 Claude Code、Codex 等 Agent 帮你接入时,把下面的地址交给它即可:完整接口说明是纯 Markdown,便于直接阅读。
隐私和安全
- 灵动岛和锁屏在手机锁定时也能被旁人看到:标题和摘要不要放敏感信息。
- 推送内容经过 Pulse Relay 推送中转和苹果服务器,中转不保存显示内容。
- 每把钥匙只能推送给一台手机、只能改你自己网站的实时活动,用户随时可以在 App 里断开。