开放接口:API Token 与 Webhook
脚本、定时任务和其他系统可以用 API Token 调用 Castor 的接口;Castor 里的数据变化时,用 Webhook 主动推送给其他系统。
API Token
打开功能
API Token 默认关闭。管理员在「系统管理 → 系统配置 → 系统设置 → 安全」里打开「允许使用 API Token」(security.api_tokens_enabled)。关闭后已有的 Token 全部暂停使用,重新打开后恢复;演示模式下不能打开。
创建
每个用户在「个人设置 → API Token」里创建自己的 Token:
- 填写名称,选择有效期(30 天、90 天、180 天、1 年或永不过期),勾选权限
- 只能勾选自己拥有的菜单 / 按钮权限;勾选一个按钮时,它所在的页面权限会一并授予
- 创建需要近期验证过身份(10 分钟内登录或验证过,否则会先弹出「验证身份」)
- Token 形如
ck_加 43 个字符,只在创建时显示一次;之后列表里只显示前 11 位 - 每人最多 20 个有效 Token
使用
在请求头里带上 Token:
curl -H "Authorization: Bearer ck_xxxxxxxx…" \
https://admin.example.com/api/admin/users?per_page=50- 带 Bearer 的请求只按 Token 认证:不读 cookie、不需要 CSRF 头、不会创建登录会话
- 实际权限 = Token 勾选的权限 ∩ 创建人当前的权限。超级管理员创建的 Token 也只有它勾选的权限;创建人被降权后 Token 随之收紧
- 数据权限按创建人计算(见 数据权限)
- 响应里的时间一律是 UTC 的 ISO 8601(如
2026-09-27T00:46:08.836078Z);导出文件里的时间和首页统计的日期按请求头X-Time-Zone(如Asia/Shanghai)计算,不传时为 UTC - 接口列表与参数见仓库里的
docs/apifox-full.openapi.json:security里列了bearerAuth的接口可以用 Token 调用,只列cookieAuth的(账号安全、系统设置等)不行
| 情况 | 响应 |
|---|---|
| 系统设置里没有打开 API Token | 401 API Token 未开启 |
| Token 不存在、已吊销、已过期,或创建人已被删除(账号删除时其 Token 一并删除) | 401 API Token 无效或已过期 |
| 创建人已被停用 | 401 未授权访问,与未登录的请求相同 |
| 调用了不接受 Token 的接口 | 403 该接口不支持 API Token |
| Token 没有这个接口需要的权限 | 403(与普通用户无权限时相同) |
错误信息和其他接口一样按 Accept-Language 翻译;不带这个请求头时(例如直接用 curl)返回英文。
下列账号与安全类接口一律不接受 Token,即使 Token 拥有全部权限:登录 / 退出、找回密码、修改密码、验证身份、两步验证(包括管理员重置别人的两步验证)、个人设置、在线用户与会话、API Token 管理本身、AI 助手、修改系统设置与测试按钮、Webhook 的所有写操作(新增、修改、删除、重新生成密钥、发送测试、重新投递)和查看密钥。读取系统设置、Webhook 列表这类只读接口可以用。
管理
- 本人在「个人设置 → API Token」里随时吊销,吊销后下一次请求即失效
- 「系统管理 → 安全审计 → API Token」(菜单权限
system_api_tokens)列出数据权限范围内所有人的 Token,可以按名称、前缀、创建人搜索,按有效 / 已过期 / 已吊销筛选;有按钮权限system_api_tokens_revoke时可以吊销。非超级管理员不能吊销超级管理员的 Token - 每个 Token 记录最近使用时间和 IP;操作日志的
api_token_id列注明请求来自哪个 Token
后端开发
新增账号或安全相关的接口(修改密码、密钥、会话等)时,把路径加进 apps/api/src/common/api-token.ts 的 API_TOKEN_DENIED,并且在 OpenAPI 文档里它的 security 只写 cookieAuth(pnpm openapi:generate -- --strict 会检查两者是否一致)。其他接口在文档里写 cookieAuth + bearerAuth 即可,代码不用额外处理:hasMenuPermission 已经按 Token 的权限判断。
Webhook
配置
「系统管理 → 系统配置 → Webhook」(查看(含投递记录)需要 system_webhooks,新增 / 编辑 / 删除分别需要 system_webhooks_add / _edit / _delete;签名密钥、「发送测试」和重新投递需要 system_webhooks_edit):
- 推送地址:http 或 https。不能指向云服务器元数据(
169.254.169.254)等保留地址;生产环境默认也不能指向内网,需要时设置SETTINGS_ALLOW_PRIVATE_NETWORK=true。发送时在连接层会再检查一次实际 IP - 订阅事件:具体事件、某一类的全部事件(如
user.*)或全部事件(*) - 签名密钥(
whsec_…):新增后显示一次;之后在「签名密钥」里查看或重新生成 - 新增、编辑 Webhook 以及查看、重新生成签名密钥,和保存系统设置一样需要近期验证过身份(见 账号安全)
- 新增 Webhook、修改推送地址时,所有超级管理员会收到站内通知
- 「发送测试」立即发送一个
ping事件并显示结果;「投递记录」列出每次投递的请求内容、响应和重试情况,可以重新投递
事件
| 事件 | 触发时机 | data |
|---|---|---|
user.created / user.updated / user.deleted | 新增用户;修改资料、状态、角色(包括本人在个人设置里改资料);删除 | 用户(不含密码);删除时为 { id, username } |
role.created / role.updated / role.deleted | 新增角色;修改名称、权限、数据范围;删除 | 角色;删除时为 { id, code } |
department.created / department.updated / department.deleted | 新增部门;修改(包括更换上级部门);删除 | 部门;删除时为 { id, code } |
<模块>.created / .updated / .deleted | 用 pnpm scaffold 生成的模块 | 记录;删除时为 { id } |
ping | 「发送测试」按钮 | { message, webhook } |
事件在业务数据写入成功之后发出;推送失败不会影响业务操作。导入和调整部门排序不发事件。
请求格式
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: castor-kit-webhook
X-Castor-Event: user.created
X-Castor-Delivery: 8939b329-81ce-47fe-b5a6-e8746a0cbaa1
X-Castor-Timestamp: 1790410668
X-Castor-Signature: sha256=e6f82d49…
{"id":"8939b329-…","event":"user.created","created_at":"2026-09-26T08:17:48.687000Z","data":{…}}- 返回任意 2xx 表示成功。重定向不会跟随,算作失败
- 10 秒超时;响应体只保存前 2000 个字符
X-Castor-Timestamp是发送时间(Unix 秒);请求体里的created_at是事件发生的时间- 失败后按 1 分钟、5 分钟、30 分钟、2 小时、6 小时重试,第 6 次仍失败记为「失败」。停用 Webhook 后排队中的重试也会停止
- 第一次发送由处理这次修改的进程立即发出;重试由定时任务调度器发送,调度器没有运行时不会重试(见 定时任务)
X-Castor-Delivery是事件 ID,重试和手动重新投递都不变,接收方可以用它去重
校验签名
签名是用密钥对「时间戳 + . + 原始请求体」计算的 HMAC-SHA256。接收方应该用原始请求体(不要先解析再序列化)计算并比较,同时拒绝时间戳太旧的请求防止重放:
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(secret, headers, rawBody) {
const timestamp = headers['x-castor-timestamp']
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false
const expected = `sha256=${createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')}`
const actual = headers['x-castor-signature'] ?? ''
return actual.length === expected.length && timingSafeEqual(Buffer.from(actual), Buffer.from(expected))
}import hashlib, hmac, time
def verify(secret: str, headers, raw_body: bytes) -> bool:
timestamp = headers["X-Castor-Timestamp"]
if abs(time.time() - int(timestamp)) > 300:
return False
digest = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(headers.get("X-Castor-Signature", ""), f"sha256={digest}")在自己的模块里发事件
pnpm scaffold 生成的模块已经会发 <模块>.created / updated / deleted。手写的模块照这个方式接入:
// routes.ts
import { declareEvents } from '@/common/webhooks'
declareEvents({ 'device.created': '设备已新增', 'device.updated': '设备已修改', 'device.deleted': '设备已删除' })
export async function registerDeviceRoutes(app: FastifyInstance) {
const service = new DeviceService(app.db, app.events)
// …
}
// service.ts: after the transaction committed
const item = deviceToDict(row)
await this.events?.emit('device.created', item)- 在事务提交之后调用
emit,事务回滚时就不会发出事件 emit写好投递记录就返回(发送在后台进行),从不抛错;不要用它决定业务逻辑- 推送的是
xxxToDict()的输出,不要带密码哈希、密钥之类的字段 declareEvents里的说明是 Webhook 页面上显示的中文原文,译文加到apps/web/src/modules/admin/pages/webhooks/locales/
