Open API: API tokens and webhooks
Scripts, cron jobs and other systems call Castor's API with API tokens; when data changes in Castor, webhooks push it to other systems.
API tokens
Turning them on
API tokens are off by default. An administrator turns on "Allow API tokens" (security.api_tokens_enabled) under System → Configuration → System settings → Security. Turning it off pauses every existing token until it's turned back on; it can't be turned on in demo mode.
Creating a token
Each user creates their own tokens under Profile → API Token:
- Enter a name, pick an expiry (30 days, 90 days, 180 days, 1 year or never) and check permissions
- Only menu / button permissions you have can be checked; checking a button also grants the page it belongs to
- Creating a token needs a recent identity check (a sign-in or check within 10 minutes; otherwise the identity check dialog opens first)
- A token looks like
ck_plus 43 characters and is shown only once, at creation; the list shows its first 11 characters - At most 20 active tokens per user
Using a token
Send the token in the request header:
curl -H "Authorization: Bearer ck_xxxxxxxx…" \
https://admin.example.com/api/admin/users?per_page=50- A request with a Bearer token is authenticated by the token only: no cookie is read, no CSRF header is needed and no session is created
- Effective permissions = the token's permissions ∩ the creator's current permissions. A super admin's token also has only the permissions checked on it; when the creator loses a permission, the token loses it too
- Data scope follows the creator (see Data scope)
- Times in responses are ISO 8601 in UTC (e.g.
2026-09-27T00:46:08.836078Z); times in exported files and the dashboard's days follow theX-Time-Zonerequest header (e.g.Asia/Shanghai), UTC when it's missing - Endpoints and parameters are in
docs/apifox-full.openapi.jsonin the repository: operations whosesecuritylistsbearerAuthaccept tokens; those listing onlycookieAuth(account security, system settings and similar) don't
| Situation | Response |
|---|---|
| API tokens aren't turned on in System settings | 401 API tokens are not turned on |
| The token doesn't exist, was revoked or expired, or its creator was deleted (their tokens are deleted with the account) | 401 The API token is invalid or has expired |
| The token's creator is disabled | 401 Unauthorized, as for a signed-out request |
| The endpoint doesn't accept tokens | 403 This endpoint does not accept API tokens |
| The token lacks the permission the endpoint needs | 403 (as for a user without the permission) |
Error messages follow the Accept-Language header like every other API error; without the header (e.g. plain curl) they are in English.
These account and security endpoints never accept tokens, even a token with every permission: sign-in / sign-out, password reset, changing the password, identity checks, two-step verification (including an admin resetting someone else's), the profile, online users and sessions, API token management itself, the AI assistant, changing System settings and its test buttons, and every webhook write (add, change, delete, regenerate the secret, send a test, redeliver) or viewing its secret. Read-only endpoints such as reading System settings or the webhook list do accept tokens.
Managing tokens
- Owners revoke their tokens under Profile → API Token at any time; the next request with the token fails
- System → Security & audit → API tokens (menu permission
system_api_tokens) lists everyone's tokens within your data scope, searchable by name, prefix and creator and filterable by active / expired / revoked; with the button permissionsystem_api_tokens_revokeyou can revoke them. Only super admins can revoke a super admin's token - Each token records when and from which IP it was last used; the
api_token_idcolumn of the operation log shows which token made a request
Backend development
When you add an account or security endpoint (password, secrets, sessions …), add its path to API_TOKEN_DENIED in apps/api/src/common/api-token.ts and list only cookieAuth in its OpenAPI security (pnpm openapi:generate -- --strict reports a mismatch). Other endpoints need nothing beyond cookieAuth + bearerAuth in the document: hasMenuPermission already checks the token's permissions.
Webhooks
Setting one up
System → Configuration → Webhooks (viewing, including deliveries, needs system_webhooks; adding / editing / deleting need system_webhooks_add / _edit / _delete; the signing secret, "Send test" and redelivering need system_webhooks_edit):
- Endpoint URL: http or https. It can't point at reserved addresses such as cloud metadata (
169.254.169.254), nor at internal networks in production unlessSETTINGS_ALLOW_PRIVATE_NETWORK=true. The actual IP is checked again when connecting - Events: single events, every event of a kind (such as
user.*) or everything (*) - Signing secret (
whsec_…): shown once after adding; view or regenerate it later under "Signing secret" - Adding or editing a webhook and viewing or regenerating its secret need a recent identity check, like saving System settings (see Account security)
- Adding a webhook or changing its URL sends a notification to every super admin
- "Send test" sends a
pingevent right away and shows the result; "Deliveries" lists each delivery's request, response and retries, and can redeliver
Events
| Event | When | data |
|---|---|---|
user.created / user.updated / user.deleted | a user is added; profile, status or roles change (including users editing their own profile); deleted | the user (no password); { id, username } on delete |
role.created / role.updated / role.deleted | a role is added; its name, permissions or data scope change; deleted | the role; { id, code } on delete |
department.created / department.updated / department.deleted | a department is added; changed (including a new parent); deleted | the department; { id, code } on delete |
<module>.created / .updated / .deleted | modules generated with pnpm scaffold | the record; { id } on delete |
ping | the "Send test" button | { message, webhook } |
Events are sent after the data was written; a failed push never fails the operation. Imports and department reordering don't send events.
Request format
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":{…}}- Any 2xx answer is a success. Redirects aren't followed and count as failures
- 10-second timeout; only the first 2000 characters of the response are kept
X-Castor-Timestampis the send time in Unix seconds; the body'screated_atis when the event happened- Failures are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours; if the 6th attempt fails the delivery is marked failed. Disabling a webhook also stops its queued retries
- The first attempt is made right away by the process that handled the change; retries are sent by the task scheduler, so they only happen while it runs (see Scheduled tasks)
X-Castor-Deliveryis the event id; it stays the same across retries and manual redeliveries, so receivers can de-duplicate on it
Verifying the signature
The signature is HMAC-SHA256 with the secret over "timestamp + . + raw request body". Compute it over the raw body (don't parse and re-serialize it) and reject old timestamps to prevent replays:
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}")Sending events from your own module
Modules generated by pnpm scaffold already send <module>.created / updated / deleted. For hand-written modules:
// 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)- Call
emitafter the transaction committed, so a rolled-back write sends nothing emitreturns once the deliveries are stored (sending happens in the background) and never throws; don't base business logic on it- Send the
xxxToDict()output, never password hashes, secrets and the like - The labels passed to
declareEventsare the Chinese source text shown on the webhook page; add their translations toapps/web/src/modules/admin/pages/webhooks/locales/
