API reference
JSON over HTTPS. Most /v1 routes accept a browser session or a bearer token and stay inside your org and project. Some management routes need a browser session or a minimum role.
The server parses model output itself. The client sends raw stdout and echoes the prompt_hash it was given.
Endpoints
Health
GET /healthz → { status, mode, version, db, feedback_available }
This route is public.
Agent skills (public)
Unauthenticated Agent Skills catalog. npx skills add <origin> discovers
/.well-known/agent-skills/index.json (v0.2.0, relative artifact URLs, sha256: digests) then
fetches each SKILL.md. Served in both SaaS and on-prem; the origin is the server the user pointed
npx skills at.
GET /.well-known/agent-skills/index.json
GET /.well-known/agent-skills/{name}/SKILL.md
Shipped skill: thespider (guided by default; autonomous when the user clearly asks to run it).
Source files live in bundled/skills/ in this repository.
Agent downloads (public)
Unauthenticated first-party proxy of prebuilt thespider-agent binaries from a private release
store. Used by the install docs, startup.md, and thespider-agent update.
GET /downloads/agent/latest.json
GET /downloads/agent/v{semver}/manifest.json
GET /downloads/agent/v{semver}/SHA256SUMS
GET /downloads/agent/v{semver}/thespider-agent-{platform}
GET /downloads/agent/latest/thespider-agent-{platform}
GET /downloads/agent/latest/SHA256SUMS
Platform IDs: linux-x86_64, linux-aarch64, darwin-x86_64, darwin-arm64, and
windows-x86_64 (whose binary name ends in .exe). Direct links are on
Agent downloads. Optional
query ?source=install|update|docs is recorded for analytics only. Binary responses include
X-Checksum-SHA256. Rate-limited per IP (429 rate_limited). Returns 404 when the channel is
disabled (AGENT_DOWNLOADS_PUBLIC=0) or the object is missing.
Device authorization (CLI setup/login)
Interactive agent login. The CLI starts a session, opens /activate?code=XXXX-XXXX, and waits
(SSE or poll) for the signed-in user to authorize the CLI as themselves. Approve issues an
agent identity token (user-linked, not org-scoped). Setup chooses the org/project after approval.
The browser never receives client_token — only the CLI does, once,
via device_code.
POST /v1/device/start
{ client_name?, project? }
→ { device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval }
GET /v1/device/wait?device_code=… # SSE: event pending|approved|denied|expired
POST /v1/device/token # poll fallback
{ device_code }
→ { client_token, client_id, org_id: null, user_id, kind: "agent_identity" }
| { error: authorization_pending|… }
GET /v1/device/pending?code=XXXX-XXXX # any signed-in session (no org required)
POST /v1/device/approve # any signed-in session
{ user_code }
POST /v1/device/deny # any signed-in session
{ user_code }
Agent identity (multi-org discovery + mint)
After device login, the identity token may call:
GET /v1/agent/me
→ { kind: "agent_identity", user_id, token_id, user_status }
GET /v1/agent/memberships
→ { memberships: [{ org_id, slug, name, role, org_status }] }
GET /v1/agent/orgs/{orgIdOrSlug}/projects
→ { org, projects[] } # membership required
POST /v1/agent/client-token
{ org_id | org_slug, project_id? }
→ { client_token, client_id, org_id, project_id, kind: "client" } # member+; rate-limited
POST /v1/agent/bootstrap
→ { org, project, profiles, models, readiness_url } # zero-membership identity only; idempotent
Org-scoped client tokens cannot list memberships (403 identity_required). Identity tokens
cannot claim/results or call GET /v1/me (403 client_token_required).
Enroll (headless / CI)
POST /v1/enroll
{ enrollment_code }
→ { client_token, client_id, org_id }
Enrollment codes are minted from the signed-in activation page (manual/CI path). They are single-use and expiring, and issue org/project-scoped client tokens (not multi-org identity). Prefer device authorization for interactive machines.
Identity & catalog
GET /v1/me → { org, projects[], models[], entitlements } # client token or session
GET /v1/me/memberships → { memberships: [{ org_id, slug, name, role, org_status }] } # browser session only
GET /v1/me/projects/{id} → { project_id, org: { id, slug, name, role, status } } # browser session only
GET /v1/models → [{ code, display_name, provider }] # NO command_template — ever
GET /v1/me is org-scoped (the token's or session's active org). It does not list all of a
user's memberships. For multi-org discovery, a browser session uses /v1/me/memberships (no active
org needed; banned orgs omitted) and an identity token uses /v1/agent/memberships.
GET /v1/me/projects/{id} answers "which of my organizations owns this project?" without an active
org, so the web app can open a /projects/{id} link that belongs to a different org than the one
currently selected: it selects the owner before loading the page. A project of an organization the
caller is not a member of, a banned or deleted organization, and a project that does not exist are
the same 404 project_not_found, so it reveals nothing about other tenants.
The model catalog has no command_template; machine-specific commands live in the client's project
config.toml.
Organization and membership
GET /v1/org
POST /v1/org # SaaS browser session only
POST /v1/org/invitations # admin+
POST /v1/invitations/accept # browser session only
PATCH /v1/org/members/{userId} # admin+
DELETE /v1/org/members/{userId} # admin+
GET /v1/orgs/usage
GET /v1/orgs/attention # per-project finding counts + newest unfinished campaign
GET /v1/orgs/attention backs the dashboard's "where you left off" cards. It returns one row per
visible project: live-finding counts (open, severe_open, new, triaged, needs_retest,
fixed), the last claim/result time, and the newest campaign that still has outstanding work
together with the models that hold its remainder (waiting_models). It is read-only and scoped to
the caller's org by the same row-level security as every other tenant read.
Roles are viewer, member, admin, and owner. Owner-specific safeguards prevent removing or demoting the final owner.
Browser organization creation
POST /v1/org
Cookie: better-auth.session_token=...
{ name, slug }
→ { id, name, slug, plan, role: "owner" }
A signed-in Cloud user can create multiple organizations. Each session-only operation creates an
organization and owner membership atomically and returns 409 if the slug is taken. Invited users
can join existing organizations and create organizations of their own.
Dashboard feedback
POST /v1/feedback
Cookie: better-auth.session_token=...
{ type: feature|bug|enhancement|question, title, description, page? }
-> { sent: true }
This browser-session-only endpoint emails the feedback and authenticated account identity to FEEDBACK_RECIPIENT. It returns 503 feedback_unavailable unless both the Postmark mailer and recipient are configured, 429 rate_limited when the per-user submission budget is exhausted, and 502 feedback_send_failed when delivery fails; feedback is not persisted in the database. GET /healthz exposes a boolean feedback_available capability so clients can hide the form when it is disabled.
Projects, profiles, configuration, and enrollment codes
POST /v1/projects
{ name, default_profile?, slug?, repo_remote_url?, default_branch? }
PATCH /v1/projects/{id}
GET /v1/projects/{id}/profiles
GET /v1/projects/{id}/profiles/{security|bugs}/config-version
GET /v1/projects/{id}/profiles/{security|bugs}/config?version=N
POST /v1/projects/{id}/profiles/{security|bugs}/config-versions
GET /v1/projects/{id}/readiness?profile=security|bugs&config_version=N
PATCH /v1/projects/{id}/models
GET /v1/projects/{id}/models/disable-preview?code=<code>[&profile=security|bugs]
GET /v1/projects/{id}/enrollment-codes
POST /v1/projects/{id}/enrollment-codes
DELETE /v1/projects/{id}/enrollment-codes/{codeId}
Project creation, metadata changes, config-version creation, model enablement, and enrollment-code
management require admin or owner. Every project contains Security and Bugs profiles.
default_profile is security unless supplied. Unqualified legacy config routes resolve to that
default and include the resolved profile in their response; new clients use the canonical routes.
PATCH /v1/projects/{id}/models takes { code | model_id, enabled?, profile?, replace_starter? }.
With replace_starter: true, enabling an agent model also disables the project's starter model when
that row is still exactly what provisioning wrote (no explicit enablement since) and no slice of it
was ever claimed in the project. The response lists them in disabled_starter_models.
Disabling skips slices. PATCH with enabled: false also skips the model's unfinished slices in
the same transaction (skip_pending, default true; send false to opt out), for the targeted
profiles only: pending slices, failed slices with attempts left, and running slices whose lease
already expired become skipped (reason model-disabled in error_message). A running slice on a
live lease is left to finish. A campaign with nothing else outstanding completes. The response adds
previously_enabled_profiles and skipped_slices:
{ "pending": 6, "retryable": 0, "expired_lease": 0, "in_flight": 0, "contended": 0,
"campaigns": [{ "campaign_id": 15, "name": "...", "status": "running", "skipped": 6 }] }
contended counts slices a concurrent claim or result held locked and were not skipped; repeating the
request skips them. Disabling twice is idempotent. While a model is explicitly disabled for a campaign's
profile its slices cannot be claimed, even before they are skipped. Re-enabling does not touch slices:
creating or reusing a campaign (POST .../campaigns) turns the skipped slices of a re-enabled model
back to pending with a fresh attempt budget.
GET .../models/disable-preview returns the same profiles, previously_enabled_profiles and
skipped_slices without writing anything, under the same admin or owner requirement. It is a
separate route, not a flag on the PATCH, so a server that predates it can only answer 404 rather
than perform the real change.
Readiness returns the selected profile/config, bucket/pass/model/planned-slice counts, enabled models,
ready, blockers, blockers_detail, and activity. Blocker codes are profile_not_found
(returned as a 404 error, not a blocker entry), config_missing, no_buckets, no_passes,
no_enabled_models, and empty_matrix.
blockers is the stable code list. blockers_detail repeats it in the same order as
{ code, message, remedy } so a terminal, the dashboard, and these docs show identical wording;
treat the codes as the contract and the strings as display text. activity is
{ last_claim_at, last_result_at } for the selected profile — both null until an agent claims.
A project created through POST /v1/projects is campaign-ready on the first response: both profiles
are seeded with the bundled starter methodology and the starter model (xai/grok-4.6) is enabled, so readiness
answers ready: true with planned_slices > 0 and no configuration step is required first.
Project reads and triage
GET /v1/projects/{id}/dashboard/risk
GET /v1/projects/{id}/dashboard/models
GET /v1/projects/{id}/campaigns
GET /v1/projects/{id}/campaigns/claimable
GET /v1/projects/{id}/config
GET /v1/projects/{id}/findings
GET /v1/projects/{id}/findings/{findingId}
GET /v1/projects/{id}/slices
GET /v1/projects/{id}/findings/export
POST /v1/projects/{id}/findings/{findingId}/status # member+
POST /v1/projects/{id}/findings/{findingId}/links # member+
DELETE /v1/projects/{id}/findings/{findingId}/links?url= # member+
Profile-aware reads accept profile=security|bugs|all. Omission resolves to the project default,
except a selected campaign supplies its pinned profile. A contradictory campaign/profile pair is
rejected. all is explicit, never the default. Finding export includes selected-profile metadata and
only matching report provenance while retaining project-global canonical status.
Findings filters
GET /v1/projects/{id}/findings and GET /v1/projects/{id}/findings/export share one filter
implementation, so everything below applies identically to both.
status and severity accept one or more values, comma-separated. Values are trimmed, empties
are dropped, and duplicates are de-duplicated. A single value behaves exactly as it always has.
GET /v1/projects/{id}/findings?status=new,triaged
GET /v1/projects/{id}/findings?severity=critical,high
status also accepts three aliases. They mean "everything except these statuses," so an unknown
legacy status still lands on the right side. Mix aliases and literals (status=actionable,accepted);
the result is their union.
| Token | Meaning |
|---|---|
open | Everything except fixed and false_positive |
actionable | Everything except wont_fix, false_positive, accepted, and fixed (what the PR gate still blocks on) |
closed | Exactly fixed or false_positive |
accepted and wont_fix stay open (dashboard still counts them) but drop out of actionable. Use actionable to decide whether to do work; use open to report state. ?status=open matches summary.open in the same response.
severity recognizes one reserved token, none, meaning "no severity was assigned". It matches
either spelling of that: a NULL canonical_severity (the bucket severityCounts labels (none)) or
the literal string none, which a model can emit. It combines with real severities:
| Filter | Matches |
|---|---|
severity=high | exactly high |
severity=critical,high | critical or high — excludes unclassified findings |
severity=none | only findings with no severity assigned — NULL or the literal none |
severity=critical,high,none | critical, high, or unclassified |
Unknown status or severity tokens are not rejected; they are matched literally and simply return nothing. Filters still combine with AND across parameters.
has_link=true keeps findings that have at least one link; has_link=false keeps
those with none. Any other value is 400 invalid_has_link. List rows carry links[], and
summary.linked counts the linked findings in the filtered set.
Finding status writes
POST /v1/projects/{id}/findings/{findingId}/status
{ status, notes?, decider?, reason?, commits?: [sha…], commit?: sha, … }
→ { finding_id, from_status, to_status, actor_user_id }
commits (or the legacy single commit) is accepted only with status=fixed or
status=needs_retest. Any other status returns 400 commits_invalid_for_status.
fixed+ commits writes a verified fix row and setsfixed_at_committo the last SHA.needs_retest+ commits is refine's proposed fix: same commit rows,fixed_at_commitset, finding stays actionable. Only a finished retest writesfixedorstill_vulnerable. See Refine.false_positiverequiresreason(400 missing_reasonotherwise).pr_urlis a deprecated spelling of "link this finding to a pull request": it attaches a finding link (at any status) and must be anhttp(s)URL (400 invalid_pr_url). A merged-away finding returns409 finding_merged. Prefer the links routes below.
Finding links
A finding can carry any number of links to merge requests, pull requests or issues (up to 50). A link is just a URL; the server derives everything else from it and calls no forge API.
POST /v1/projects/{id}/findings/{findingId}/links { url }
→ 201 { finding_id, created: true, link } # new link
→ 200 { finding_id, created: false, link } # already attached: idempotent, never a duplicate
DELETE /v1/projects/{id}/findings/{findingId}/links?url=<url>
→ 200 { finding_id, removed: true, link }
link is { id, url, provider, kind, ref, host, actor_user_id, created_at }:
| Field | Values |
|---|---|
provider | github, gitlab, bitbucket, azure_devops, gitea, jira, linear, other |
kind | merge_request, pull_request, issue, other |
ref | !128 (merge request), #57 (pull request, issue), PROJ-9 (Jira, Linear), or null |
Both routes need the member role, the same as a status change, and work with a session or a client
token (actor_user_id is null for a token). Errors: 400 invalid_url (not an http(s) URL, longer than
2048 characters, contains whitespace), 403 insufficient_role / read_only_license, 404 finding_not_found, 404 link_not_found (delete of a URL the finding does not carry), 409 finding_merged (the finding was merged away; the body names merged_into_finding_id), 409 too_many_links.
The URL is canonicalized before it is compared or stored: scheme and host lower-cased, credentials,
fragment and trailing slash dropped, and a sub-page of a known change collapsed to the change itself
(…/pull/57/files is …/pull/57), so the same request linked from two tabs is one link. provider,
kind and ref come from the URL's host and path, and an unrecognised host is other. Soft-merging
two findings moves the loser's links to the keeper.
Campaigns
POST /v1/projects/{id}/campaigns
{ profile, type, git:{branch,commit,remote_url,dirty}, base_commit?, changed_paths?, config_version? }
→ { campaign_id, profile, profile_id, config_version, config_version_id, slice_count, status, reused }
changed_paths for an incremental campaign,
or use the GitHub webhook path. Full campaign identity includes profile and pinned config ID.
Retests require an explicit profile and return 422 retest_bucket_unmapped when the finding's source
bucket code does not exist in the target profile config.Claim, heartbeat, results
POST /v1/campaigns/{id}/claim
{ client_id, models:[...], max_slices, lease_seconds }
→ { leases:[ { lease_id, slice_id, model_code, pass_code, bucket_code,
prompt:"<full rendered prompt.md>", prompt_hash, marker,
timeout_seconds, lease_expires_at } ] }
POST /v1/leases/{lease_id}/heartbeat → { lease_expires_at } # best-effort, online clients only
POST /v1/leases/{lease_id}/results
{ exit_code, # REQUIRED: number, or null for "killed before it exited"
duration_ms, model_code, client_version, prompt_hash,
stdout:"<raw output.txt>",
stderr_tail }
→ { accepted, parsed_report_count, warnings[], error? } # idempotent on (lease_id, prompt_hash)
exit_code decides coverage validity, so it is the one required field of the body. An absent field
or a non-numeric one ("0") is answered 400 missing_exit_code / 400 invalid_exit_code — a
payload error, not the 422 coverage verdict, so an integrator is not sent looking at their runner
for a bug in their request.
Gate
GET /v1/campaigns/{id}/gate → { status: pass|fail|pending|degraded, pending_slices, failed_slices, skipped_slices, new_findings:[...] }
Cheap CI poll — no model creds or long runtimes in CI.
The gate route is authenticated. CI needs a TheSpider token even though it does not need model credentials.
Lease policy
The client requests lease_seconds sized to its batch (server caps it, e.g. 24h). Heartbeats extend a lease when the client is online but are not required — claim and submit are the only calls that need connectivity. On expiry the slice requeues.
- A late result is still accepted if no other lease has completed the slice with a valid marked array and exit 0.
- If one has, first-accepted-wins and the late output is stored as a provenance artifact.
- A missing marker, unclosed marker, empty marker body, malformed JSON, a value that is not an array of objects, or a nonzero/absent exit stores the artifact, marks the slice
failed(retryable), and returns422. - Retries are bounded. A slice is offered at most
MAX_SLICE_ATTEMPTS(default 3) times. After that it staysfailedbut is no longer claimable and no longer counts as outstanding, so the campaign reaches a terminal status and the gate reaches a verdict instead of hanging on a deterministically-broken runner. The run is reported as degraded, never clean.requeue-invalidresets the counter — that is the operator's "I fixed the binding, try again". - Slice-quota usage is recorded once at claim, idempotent per
slice_id. Billing integration is not shipped.
GET /v1/projects/{id}/campaigns/{campaignId}/coverage
→ { valid, invalid, unverified, items:[{ slice_id, validity, reasons[] }] }
POST /v1/projects/{id}/campaigns/{campaignId}/requeue-invalid # member+ role
→ { requeued, valid, campaign_status, removed_findings, stranded_findings:[id] }
GET /v1/projects/{id}/findings/merge-suggestions?limit=50 # max 200
→ { suggestions:[{ keeper_id, other_id, confidence, rationale }] }
requeue-invalid takes back what the invalid runs produced, not just their slice rows: their
reports are deleted, and a finding left with no supporting report is deleted with them —
removed_findings counts those. A finding a human has touched (triaged, fixed, dismissed) or that
something else points at (a merge keeper, a retest target) is never deleted out from under them; it
is reported in stranded_findings instead, so a successful repair cannot hide a remnant.
merge-suggestions is a bounded review aid, not a report: it stops after limit suggestions and
after a bounded number of findings examined, because the cost is per finding walked. It never
merges anything.
Error semantics
| Status | Meaning | Client action |
|---|---|---|
200 | accepted (valid coverage) | — |
202 | accepted-as-artifact / superseded | — |
400 | missing_exit_code / invalid_exit_code — the results body omitted exit_code or sent it as something other than a number or null | fix the payload; resubmitting it unchanged always fails |
409 | prompt_hash mismatch | re-claim |
413 | stdout exceeds MAX_ARTIFACT_BYTES | reduce output or enable --redact; presigned upload is not implemented |
422 | parse error or nonzero runner exit | claim again; the slice was not counted as coverage |
429 | rate limited | retry with backoff |
5xx | server error | retry with backoff |
Network errors, 429, and 5xx are retried with exponential backoff + jitter by the agent.
Detailed integration contracts:
Webhooks (optional)
The Cloud webhook integration is not self-service yet. When TheSpider enables it for a project,
POST /v1/webhooks/github verifies GitHub's signature, maps pull_request opened, synchronize,
or reopened events to a project, fetches changed paths, and creates an incremental campaign.