SubMan Server API is an owner-operated REST API for trusted backend scripts.
The first version is designed for automation such as sing-box-vps installing
or updating nodes in the SubMan workspace.
The API runs inside the existing SvelteKit app on Cloudflare Workers. It writes to the same Workspace Gist used by the browser UI.
The machine-readable source for schemas, operation IDs, parameters, response
headers, and examples is openapi.yaml.
The supported public API consists of GET /api/health and the node endpoints
documented below. The current unversioned paths are the first compatibility
generation: compatible changes should remain additive. A future breaking
contract must use a new versioned path or an explicitly negotiated version.
Subscriptions, aggregate rules, publication, output files, and client exports do
not yet have public REST endpoints. External callers must not PATCH the Gist or
call /api/workspaces/:workspaceId/mutations. That route is an internal browser
transport with a different credential and mutation envelope and has no public
compatibility guarantee.
The staged design for expanding this surface is documented in
roadmap.md. It does not change the current supported contract.
Use the origin where SubMan is deployed:
https://subman.example.com
Examples below use https://subman.example.com. Replace it with your deployed
SubMan domain.
All endpoints except GET /api/health require:
Authorization: Bearer <SUBMAN_API_TOKEN>SUBMAN_API_TOKEN is your own SubMan API token. It is separate from the GitHub
token, but it is one shared full-access bearer: there are no scopes, per-client
identities, per-client revocation, or built-in caller rate limits. Give it only
to trusted backend scripts over TLS, keep it out of URLs and logs, and rotate it
periodically. CORS is not an authentication boundary.
Before using the API in Cloudflare Workers, configure two secrets:
bun wrangler secret put GITHUB_TOKEN
bun wrangler secret put SUBMAN_API_TOKENGITHUB_TOKEN must have GitHub gist permission. The Worker uses it to find or
create the Workspace Gist and passes it separately to the Workspace coordinator
for each request. It is never included in a mutation payload.
SUBMAN_API_TOKEN is an arbitrary long secret value that callers must send in
the Authorization header.
Then deploy:
bun run build
bun run deployThe API uses the same workspace identity as the UI:
- Gist description:
SubMan-Data - Workspace file:
subman.json
If the Workspace Gist does not exist, the API creates only a bootstrap marker.
Each write builds a revisioned node mutation and submits it to the
WorkspaceCoordinator Durable Object for that Gist. The coordinator reads the
latest document, applies one mutation, and commits subman.json in a verified
Gist PATCH. Browser and Server API writes therefore share the same ordering,
idempotency, tombstone, and conflict rules.
Public Node API writes are synchronous. A write 2xx response means the coordinator
committed and read-back verified the remote Workspace. Browser completion states
such as local-durable-queued, peer-owned, and retry-scheduled do not apply
to public API callers.
Reads return the observed remote snapshot; they do not commit or publish anything.
Workspace discovery is shared with the UI, but the API does not use the browser's
saved binding. It searches the Gists accessible to the Worker's GITHUB_TOKEN.
With no matching Workspace, even a node GET can create a private bootstrap Gist;
V1 and bootstrap reads report revision 0. Multiple valid candidates or invalid
matching candidates require operator selection/repair and currently return the
sanitized 502 gist_read_failed; the API has no Workspace selector parameter.
Check workspace.gistId against the intended Gist before writing. A revision ETag
is scoped to that Workspace and does not identify the Gist by itself.
Updating a node does not regenerate existing aggregate or sing-box output files. Review and publish affected outputs separately in the browser.
The first write to a V1 Workspace preserves the exact original bytes as
subman.v1.backup.json before committing V2. See
docs/workspace-v2-operations.md for migration and rollback procedures.
For JSON requests:
Authorization: Bearer <SUBMAN_API_TOKEN>
Content-Type: application/json
If-Match: "subman-revision-12"If-Match is optional on writes. Use the strong ETag from a previous successful
node response to prevent a stale read-modify-write. If-Match: * means the
caller does not require a specific revision. A stale or weak validator returns
412 precondition_failed and the current revision.
Every successful node response includes:
ETag: "subman-revision-13"
X-SubMan-Revision: 13
Cache-Control: no-store{
"id": "string",
"name": "vps-1 vless",
"type": "vless",
"raw": "vless://...",
"tags": [
{
"id": "sing-box-vps",
"label": "sing-box-vps"
}
],
"enabled": true,
"updatedAt": "2026-05-06T00:00:00.000Z",
"source": "single"
}Allowed type values:
vlessvmesstrojanssssrhysteria2tuicanytlsother
Allowed source values:
singlesubscription
POST /api/nodes and PUT /api/nodes/by-key/:externalKey accept:
{
"name": "vps-1 vless",
"type": "vless",
"raw": "vless://...",
"enabled": true,
"tags": ["sing-box-vps", "auto"],
"source": "single"
}Rules:
nameis required.- If another node already has the same name, the API saves the node with a
timestamp suffix such as
HK 2026-05-26 06:32. typeis required and must be one of the allowed proxy types.rawis required.- If another node already has the same trimmed
rawURI, the API rejects the write with409 duplicate_node_raw. enableddefaults totrue.sourcedefaults tosingle.tagsdefaults to an empty array.tagsmay be strings or{ "id": "...", "label": "..." }objects.- Strings are trimmed and tags are deduplicated by their normalized label.
Unknown body fields are ignored. Send the documented types: create/upsert
currently defaults non-boolean
enabled, non-arraytags, and missing, empty, or non-stringsource; PATCH validates supplied optional fields.
PATCH /api/nodes/:id accepts any subset of the node create body:
{
"name": "vps-1 renamed",
"enabled": false
}Missing fields keep their existing values.
Supplying tags replaces the entire tag list, including an existing external-key
marker. To update tags on a machine-managed node while preserving its key, use
by-key PUT with the full desired node payload. Do not copy the returned
external: marker into request tags; caller-supplied markers are rejected.
Patch requests use the same duplicate handling as create requests: duplicate names are made unique, and duplicate raw URIs on another node are rejected.
List responses:
{
"data": [],
"workspace": {
"gistId": "gist-id",
"file": "subman.json",
"revision": 12
}
}Single-node write responses:
{
"data": {
"id": "node-id",
"name": "vps-1 vless",
"type": "vless",
"raw": "vless://...",
"tags": [],
"enabled": true,
"updatedAt": "2026-05-06T00:00:00.000Z",
"source": "single"
},
"workspace": {
"gistId": "gist-id",
"file": "subman.json",
"revision": 13
}
}Error responses:
{
"error": {
"code": "unauthorized",
"message": "Unauthorized",
"disposition": "auth-required"
}
}Errors always use a stable code and disposition. A GitHub failure may add a
bounded gateway object containing only operation, status, category, request ID,
retry-after, and rate-limit reset. Raw GitHub response bodies, credentials,
exception messages, stacks, and Workspace documents are never returned. A
conflict may add only { "workspace": { "revision": 13 } }. Branch on code
and disposition, not the human-readable message.
When safe GitHub metadata includes retryAfter, the response also carries the
standard Retry-After header. The gateway object is optional: discovery/read
failures that occur before coordinator submission may return a sanitized 502
without upstream metadata.
JSON writes require application/json or a +json media type. The Worker counts
streamed bytes rather than trusting only Content-Length; the request limit is 9
MiB. Invalid media type, malformed JSON, and oversize bodies return stable 415,
400, and 413 errors respectively.
Created or edited values use UTF-8/count limits: node raw 16 KiB, subscription URL 8 KiB, names 256 bytes, labels 128 bytes, external keys 256 bytes, 64 tags per entity, 5,000 entities per collection, rename maps of at most 1,000 entries and 64 KiB, output content 1 MiB, and canonical Workspace documents 8 MiB. Unchanged oversized legacy fields remain readable and may be reduced; unrelated changes do not reject them.
For by-key writes the generated external: prefix takes 9 bytes of the 128-byte
label limit. New or changed keys therefore fit 119 UTF-8 bytes, and caller
tags must leave one of the 64 slots for that marker (63 distinct labels).
The mutation protocol also has a 256-byte external-key limit; it does not bypass
the resulting tag limit. Domain limits return 400 invalid_mutation, not HTTP
413. Growing an already oversized Workspace returns 500 workspace_size_limit
with operator-repair. Limits on writes do not constrain readable legacy
responses to the same sizes.
GET /api/healthAuthentication: not required.
Returns whether the required server-side secrets are configured. Secret values are never returned.
HTTP status is always 200, including ok: false. ok: true does not validate
GitHub permissions, Workspace discovery, or the coordinator binding.
Example:
curl -sS "https://subman.example.com/api/health"Response:
{
"ok": true,
"config": {
"githubToken": true,
"submanApiToken": true
}
}GET /api/nodesExample:
curl -sS "https://subman.example.com/api/nodes" \
-H "Authorization: Bearer $SUBMAN_API_TOKEN"Response:
{
"data": [
{
"id": "node-id",
"name": "vps-1 vless",
"type": "vless",
"raw": "vless://...",
"tags": [],
"enabled": true,
"updatedAt": "2026-05-06T00:00:00.000Z",
"source": "single"
}
],
"workspace": {
"gistId": "gist-id",
"file": "subman.json",
"revision": 12
}
}POST /api/nodesCreates a new node every time unless the submitted raw URI already exists. If
the submitted name already exists, the saved name receives a timestamp suffix.
For installer scripts, prefer PUT /api/nodes/by-key/:externalKey to avoid
creating separate records for the same machine-managed node.
Example:
curl --fail-with-body -sS -X POST "https://subman.example.com/api/nodes" \
-H "Authorization: Bearer $SUBMAN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"vps-1 vless","type":"vless","raw":"vless://...","enabled":true,"tags":["manual"]}'Success status: 201 Created.
GET /api/nodes/:idExample:
curl -sS "https://subman.example.com/api/nodes/node-id" \
-H "Authorization: Bearer $SUBMAN_API_TOKEN"PATCH /api/nodes/:idExample:
curl --fail-with-body -sS -X PATCH "https://subman.example.com/api/nodes/node-id" \
-H "Authorization: Bearer $SUBMAN_API_TOKEN" \
-H "Content-Type: application/json" \
-H 'If-Match: "subman-revision-12"' \
-d '{"enabled":false,"name":"vps-1 disabled"}'DELETE /api/nodes/:idDeletes the node and removes its id from aggregate rule nodeIds.
Example:
curl --fail-with-body -sS -X DELETE "https://subman.example.com/api/nodes/node-id" \
-H "Authorization: Bearer $SUBMAN_API_TOKEN"Response:
{
"data": {
"deleted": true
},
"workspace": {
"gistId": "gist-id",
"file": "subman.json",
"revision": 13
}
}PUT /api/nodes/by-key/:externalKeyThis is the recommended endpoint for automation scripts. One stable
externalKey addresses one node, so repeated calls update that resource instead
of creating duplicates. This is resource-identity idempotency, not request
replay idempotency: each successful update may change updatedAt and advance the
Workspace revision. The API does not support Idempotency-Key.
The PUT payload replaces all writable fields; omitted enabled, source, and
tags take their create defaults rather than keeping the existing values. The
key marker is added by the coordinator. Omitting other ordinary tags removes
them. Deleting the node removes its key marker; a later by-key PUT can create a
new node ID, while the original deleted ID remains tombstoned.
It still follows normal node validation: duplicate names are made unique, and a
raw URI that already belongs to a different node is rejected with
409 duplicate_node_raw.
The API stores the external key as a hidden-style tag label:
external:<externalKey>
Choose a stable externalKey, for example:
vps-1-vlesshostname-vless-realityserver-id-protocol-port
URL-encode the key exactly once as a path segment. The framework decodes it once;
literal % and strings such as %2F remain part of the key. Matching is
case-sensitive after trimming. Keep a new or changed key within 119 UTF-8 bytes.
The external: tag label namespace is reserved for this identity mapping; do not
use it for unrelated caller tags.
Example:
curl --fail-with-body -sS -X PUT "https://subman.example.com/api/nodes/by-key/vps-1-vless" \
-H "Authorization: Bearer $SUBMAN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"vps-1 vless","type":"vless","raw":"vless://...","enabled":true,"tags":["sing-box-vps"]}'#!/usr/bin/env bash
set -euo pipefail
SUBMAN_BASE_URL="https://subman.example.com"
SUBMAN_API_TOKEN="${SUBMAN_API_TOKEN:?SUBMAN_API_TOKEN is required}"
NODE_HOSTNAME="$(hostname)"
NODE_KEY="${NODE_HOSTNAME}-vless-reality"
NODE_NAME="${NODE_HOSTNAME} vless reality"
NODE_RAW="vless://..."
PAYLOAD="$(jq -cn \
--arg name "${NODE_NAME}" \
--arg raw "${NODE_RAW}" \
'{name: $name, type: "vless", raw: $raw, enabled: true, tags: ["sing-box-vps", "auto"]}')"
curl --fail-with-body -sS -X PUT "${SUBMAN_BASE_URL}/api/nodes/by-key/${NODE_KEY}" \
-H "Authorization: Bearer ${SUBMAN_API_TOKEN}" \
-H "Content-Type: application/json" \
--data-binary "${PAYLOAD}"Use a URL encoder when NODE_KEY is not already limited to unreserved URL
characters. A JSON serializer such as jq is required when values are dynamic;
do not build JSON by string interpolation.
type SubManResult = {
error?: { code: string; disposition: string };
};
const response = await fetch(
`${submanOrigin}/api/nodes/by-key/${encodeURIComponent(externalKey)}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${submanApiToken}`,
"Content-Type": "application/json",
...(previousEtag ? { "If-Match": previousEtag } : {}),
},
body: JSON.stringify(node),
},
);
const result = (await response.json()) as SubManResult;
if (!response.ok) {
if (!result.error) throw new Error(`Unexpected HTTP ${response.status}`);
throw new Error(`${result.error.code}: ${result.error.disposition}`);
}
const committedEtag = response.headers.get("etag");Production integrations should map stable codes/dispositions to typed outcomes instead of throwing a generic error as this compact example does.
| Method | Known non-commit failure | Unknown outcome or lost response |
|---|---|---|
GET |
Retry retryable-upstream with bounded backoff. |
Retry safely. |
PUT .../by-key |
Retry only confirmed pre-write retryable failures; honor Retry-After. |
GET the list and locate the exact external:<key> tag first. Blind replay can advance revision again. |
PATCH |
Re-read on state conflict; retry only after reapplying intent. | GET first and compare the node plus revision. |
POST |
Correct validation/domain errors before a new request. | Do not blindly retry; a node may already exist. |
DELETE |
Resolve tombstone/not-found responses as current state. | GET first; repeated delete is not request-idempotent. |
The API does not accept Idempotency-Key. If-Match protects optimistic
concurrency but does not make an unknown-outcome request safe to replay.
A write error, including a GitHub timeout or 5xx after PATCH, may occur after
GitHub accepted the change. retryable-upstream describes failure handling,
not proof that no write occurred. For uncertain writes, re-read the node(s) and
revision before deciding to resubmit; verification/index failures need operator
review. Preserve safe retry timing while checking. There is no GET-by-key route.
| Status | Code | Meaning |
|---|---|---|
200 |
- | Read, update, delete, or upsert succeeded. |
201 |
- | Node created by POST /api/nodes. |
400 |
bad_request |
Invalid JSON body or unsupported field value. |
400 |
invalid_json |
The request body is missing, malformed, or invalid UTF-8 JSON. |
400 |
invalid_mutation |
A node mutation or resulting edited value violates a schema/domain limit. |
400 |
invalid_workspace_document / invalid_bootstrap_marker |
Stored Workspace data requires operator repair. |
401 |
unauthorized |
Missing or invalid SUBMAN_API_TOKEN. |
401 / 403 |
gist_read_failed / gist_write_failed |
GitHub authentication or permission failed; reconnect or repair the Worker secret. |
404 |
not_found |
Requested node id does not exist. |
404 |
entity_not_found |
A write targeted a node that does not exist. |
404 |
workspace_not_found |
The configured Workspace Gist no longer exists. |
412 |
precondition_failed |
If-Match does not match the current Workspace revision. |
409 |
duplicate_node_raw |
Submitted raw URI already belongs to another node. |
409 |
revision_conflict |
Another mutation committed after this request loaded the Workspace; retry from the latest revision. |
409 |
entity_deleted |
A stale write attempted to restore a tombstoned entity. |
409 |
migration_backup_conflict |
The existing V1 backup does not match the current V1 config. |
422 |
unsupported_schema |
The Workspace uses a newer unsupported schema. |
413 |
payload_too_large |
The streamed JSON body exceeds 9 MiB. |
415 |
unsupported_media_type |
The request is not JSON. |
429 |
gist_read_failed / gist_write_failed |
GitHub rate limited the operation; honor safe retry metadata. |
500 |
server_error |
A required Worker secret or coordinator binding is missing, or an unexpected failure occurred. |
500 |
workspace_size_limit / commit_index_failed / invalid_journal_record |
Workspace size or coordinator state requires operator repair; a commit may already exist. |
502 |
gist_read_failed / gist_write_failed / write_verification_failed |
Discovery, network, or upstream verification failed; a write may already have committed. |
504 |
gist_read_failed / gist_write_failed |
A bounded GitHub request timed out. |
- Keep
GITHUB_TOKENonly in Cloudflare Secrets. - Give backend scripts only
SUBMAN_API_TOKEN. - Rotate
SUBMAN_API_TOKENperiodically and immediately if a script host is compromised. - The Durable Object serializes concurrent mutations and rejects stale revisions, but GitHub Gist remains a low-frequency storage backend.
- Use bounded backoff and safe
Retry-After/rate-limit metadata. Arevision_conflictorprecondition_failedisstate-conflict: reload current state and deliberately reapply the intent. - Before retrying a write, establish whether it committed; an upstream error disposition alone is not non-commit evidence.
GETis safe to retry. After an unknown write outcome, re-read before acting:PUT .../by-keycan advance revision again,PATCHcan reapply a change,POSTcan create another node, and repeatedDELETEcan return a tombstone or not-found conflict.- CORS is intentionally not opened for arbitrary browser origins and must not be treated as access control.