Get Started
Reference

API reference

The mounted TheSpider HTTP API, authentication, work loop, reads, and management routes

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, and darwin-arm64. 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/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 — use /v1/agent/memberships with an identity token for multi-org discovery.

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

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}/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.

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+

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.

TokenMeaning
openEverything except fixed and false_positive
actionableEverything except wont_fix, false_positive, accepted, and fixed (what the PR gate still blocks on)
closedExactly 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:

FilterMatches
severity=highexactly high
severity=critical,highcritical or highexcludes unclassified findings
severity=noneonly findings with no severity assigned — NULL or the literal none
severity=critical,high,nonecritical, 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.

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 sets fixed_at_commit to the last SHA.
  • needs_retest + commits is refine's proposed fix: same commit rows, fixed_at_commit set, finding stays actionable. Only a finished retest writes fixed or still_vulnerable. See Refine.
  • false_positive requires reason (400 missing_reason otherwise).

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 }
The server has no source tree. An API caller must send 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, duration_ms, model_code, client_version, prompt_hash,
       stdout:"<raw output.txt>",
       stderr_tail }
  → { accepted, parsed_report_count, warnings[] }             # idempotent on (lease_id, prompt_hash)

Gate

GET /v1/campaigns/{id}/gate  → { status: pass|fail|pending, 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 requiredclaim 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.
  • If one has, first-accepted-wins and the late output is stored as a provenance artifact.
  • Slice-quota usage is recorded once at claim, idempotent per slice_id. Billing integration is not shipped.

Error semantics

StatusMeaningClient action
200accepted
202accepted-as-artifact / superseded
409prompt_hash mismatchre-claim
413stdout exceeds MAX_ARTIFACT_BYTESreduce output or enable --redact; presigned upload is not implemented
429rate limitedretry with backoff
5xxserver errorretry 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.

Next steps

Copyright © 2026