开发者文档
浏览器嵌入见 接入示例页。本页讲的是直接调 HTTP API—— 桌面应用、移动 App、以及任何非浏览器客户端。
由契约自动生成,有防漂移门禁。可直接导入 Postman / Insomnia,或用于代码生成。
client-v1/v2 是访客面(自动/人工对话),agent-v1 是人工座席面,
server-v1 是客户后端换取短期访客令牌的服务端入口,admin-v1
是管理面(应用、Origin、审计)。server/admin 均不返回任何 CORS 头,长期 Key
绝不能进入浏览器。
两个端点校验的是两份不同的 Origin 白名单。
POST /client/v1/embed-tickets 认宿主页 Origin——你在管理台里添加的那个
https://widget.aiservice.shunwanpay.com),
这是平台资产,租户管理员无权修改
用同一个 Origin 走完全程,第二步就会拿到
origin_not_allowed。这是浏览器里的真实结构—— Loader 在宿主页换票,Shell 在
iframe 里换会话。
下面每一步都已在生产环境实测通过。Origin 头是必填,不带直接 400。
POST https://api.aiservice.shunwanpay.com/client/v1/embed-tickets
Origin:
Content-Type: application/json
{"app_id": "app_E1B7MYTKM7YE44AD19HX9S7P03"}
→ 201 {"data": {"embed_ticket": "...", "expires_at": "..."}}
POST /client/v1/sessions
Origin: https://widget.aiservice.shunwanpay.com
Content-Type: application/json
{
"embed_ticket": "上一步拿到的",
"client": {"type": "widget", "version": "0.1.9"}
}
→ 201 {"data": {"access_token", "session_id", "expires_at", "capabilities"}}
client 是必填对象,type 目前只接受字面量 "widget"。
POST /client/v1/conversations
Origin: https://widget.aiservice.shunwanpay.com
Authorization: Bearer <access_token>
Idempotency-Key: <16-128 字符,[A-Za-z0-9._:-]>
Content-Type: application/json
{}
→ 201 {"data": {"conversation_id", "state", "state_version", "owner_epoch"}}
POST /client/v1/conversations/<conversation_id>/messages
Origin: https://widget.aiservice.shunwanpay.com
Authorization: Bearer <access_token>
Idempotency-Key: <每条消息一个新的>
Content-Type: application/json
GET /client/v1/conversations/<conversation_id>/events
Origin: https://widget.aiservice.shunwanpay.com
Authorization: Bearer <access_token>
→ 200 事件页(带游标,按序号连续)
回复是异步的:发消息返回后回复尚未生成,要靠轮询事件拿到。没有 WebSocket。
| 方法 | 路径 | 必需头 |
|---|---|---|
| POST | /client/v1/embed-tickets |
Origin |
| POST | /client/v1/sessions |
Origin |
| POST | /client/v1/visitor-sessions |
Origin · Idempotency-Key |
| POST | /client/v1/sessions/refresh |
Origin · Idempotency-Key |
| POST | /client/v1/sessions/remember |
Origin · Idempotency-Key |
| POST | /client/v1/sessions/forget |
Origin · Idempotency-Key |
| POST | /client/v1/conversations |
Origin · Idempotency-Key |
| POST | /client/v1/conversations/{id}/messages |
Origin · Idempotency-Key |
| POST | /client/v1/conversations/{id}/handoff-requests |
Origin · Idempotency-Key |
| POST | /client/v1/realtime-tokens |
Origin · Idempotency-Key |
| GET | /client/v1/conversations/{id}/events |
Origin |
Idempotency-Key:16–128 字符,只允许 [A-Za-z0-9._:-]。
同一个键重放同一请求是安全的;同键不同请求体会失败关闭。
sk_server_* 只属于客户后端。不要把它写进 HTML、Widget、游戏客户端、 移动
App、日志或仓库。请在管理台“服务端 API”页创建,并立即保存到服务端秘密管理器;原始 Key
只显示一次。
POST https://api.aiservice.shunwanpay.com/server/v1/visitor-tokens
Authorization: Bearer <sk_server_*>
Idempotency-Key: <由客户后端稳定生成的 16–128 字符键>
Content-Type: application/json
{
"app_id": "app_...",
"external_user_ref": "你方系统中的稳定用户标识",
"expires_in": 300
}
→ 201 {"data":{"visitor_token":"vst_...","token_type":"Bearer","expires_at":"..."}}
平台不保存 external_user_ref 原文,只保存用途隔离、带版本的 HMAC。相同 Key、
相同请求体与相同 Idempotency-Key 会恢复同一令牌;同键不同正文返回 409。
POST /client/v1/visitor-sessions
Origin: https://widget.aiservice.shunwanpay.com
Idempotency-Key: <当前浏览器会话的稳定键>
Content-Type: application/json
{
"visitor_token": "vst_...",
"embed_ticket": "emb_...",
"client": {"type":"widget","version":"0.1.9"}
}
→ 201 {"data":{"access_token","session_id","expires_at","capabilities"}}
visitor_token 只能消费一次,并与 embed ticket 的应用、允许宿主页 Origin 和平台
Widget Origin 同时核对。成功后继续使用上面的 Conversation → Message → Events → Handoff
协议,不另造一套消息流。
import { createServerClient } from "@aiservice/sdk-typescript";
const serverApiKey = process.env.AISERVICE_SERVER_API_KEY;
if (!serverApiKey) throw new Error(\"AISERVICE_SERVER_API_KEY 未配置\");
const client = createServerClient({
apiBaseUrl: "https://api.aiservice.shunwanpay.com/server/v1",
serverApiKey,
fetch
});
const token = await client.createVisitorToken({
appId: "app_...",
externalUserRef: authenticatedUser.id,
idempotencyKey: requestScopedStableKey,
expiresInSeconds: 300
});
SDK 是 Node-only;检测到浏览器运行环境会拒绝实例化。它不会自动生成幂等键,也不会把服务端 错误正文、Key 或外部用户标识放进异常对象。
上面的流程在原生客户端里同样可用——直接发 HTTP 即可,把 Origin
当成一个普通请求头填上。
要理解 Origin 在这里意味着什么。浏览器会强制页面不能伪造自己的 Origin,所以白名单能挡住「未授权站点嵌入 Widget」。但原生客户端可以填任意值—— Origin 对原生客户端不是安全边界。当前访客面没有为原生客户端设计的独立凭据机制, 请据此评估你的威胁模型。
仓库里有 C++17 与 C#/.NET 两个 SDK,但它们是 Protocol Core,不是完整客户端:
| 提供 | 不提供 | |
|---|---|---|
| C# / .NET netstandard2.1 · net10.0 |
事件页解析与校验、会话游标、序号连续性、重复事件识别;可注入的读取 Transport 与取消 | 不保存 Access / Embed / Cookie |
| C++17 CMake 静态库 |
对已解析的 typed DTO 做协议校验 | 网络 Transport、取消、线程模型全部由宿主实现 |
换句话说:HTTP 那一层要你自己写,SDK 负责的是「拿到事件页之后别读错」—— 序号断裂、重复事件、游标推进这些容易写错的部分。
传输层、Origin、凭据签发、Realtime、Unity / Unreal 适配均未标记 GA。 SDK 版本与服务协议独立演进。
事件除了轮询,也可以通过 WebSocket 推送。推送与轮询走完全同一条读取路径, 因此两者在同一游标下产出的事件序列逐字节相同——这一点有门禁强制。
POST /client/v1/realtime-tokens
Origin: https://widget.aiservice.shunwanpay.com
Authorization: Bearer <access_token>
Idempotency-Key: <16-128 字符>
→ 201 {"data": {"realtime_token", "realtime_url", "realtime_expires_at"}}
→ 404 本部署未启用实时通道
404 是稳定事实,不是暂时故障——重试多少次结果都一样,请回落到轮询。
令牌走 Sec-WebSocket-Protocol,不走查询串——查询串会进入
反向代理与服务端的访问日志,而实时令牌本该只存在于内存。
new WebSocket(realtime_url, ["aiservice.realtime.v1", realtime_token])
{"type":"subscribe","conversation_id":"conv_...","after_sequence":"0"}
游标由客户端携带,服务端不记「上次推到哪」。断线重连时带上你收到的 最后一个
sequence,既不会丢事件也不会重复。
{"type":"event","conversation_id","event_id","sequence","event_type","payload","occurred_at"}
{"type":"heartbeat"}
| 码 | 含义 | 该怎么做 |
|---|---|---|
| 4001 | 令牌到期 | 重新换令牌再连——连接内不续期 |
| 4002 | 你消费不过来 | 降低处理延迟后重连,并从游标续传 |
| 4003 | 握手或帧不合法 | 检查令牌与帧格式;原因不会回显 |
站点有 CSP 时,connect-src 需同时放行 https:// 与
wss://——前者不会自动覆盖后者,漏了会让握手在浏览器里被拦下,
而服务端一条记录都没有。
受限网络里 WebSocket 的 Upgrade 握手常被中间层拦掉。此时可以改用
text/event-stream——协议逻辑完全相同,订阅、游标、补读、
背压、到期断开都来自同一份实现,只有传输不一样。
GET /realtime/v1/events?conversation_id=conv_...&after_sequence=0
Authorization: Bearer <realtime_token>
Origin: https://widget.aiservice.shunwanpay.com
→ 200 text/event-stream
不能用 EventSource:它不允许设置请求头,而令牌必须走
Authorization。放进查询串会留在访问日志里,放进 Cookie
则要引入一份新的持久凭据——两者都更差。请用流式 fetch 读响应体。
id: 7
data: {"type":"event","sequence":"7",...}
: ping
event: close
data: {"code":4001,"reason":"token expired"}
id: 就是事件序号,因此标准的
Last-Event-ID 续传语义可直接使用,且它优先于查询串里的
after_sequence。 心跳是注释行(: ping),不占
id——否则重连游标会被心跳污染。
SSE 没有关闭码,所以结束前会发一帧 event: close 说明原因。
没有它,客户端无法区分「令牌到期该换一张」与「网络抖动直接重连」。
SSE 是单向的:订阅参数只能来自请求本身,发消息等写操作仍走 HTTP POST。
visitor_tokens:write Scope 内。