API overview
Everything you can do in the Hyrax web app you can also do over REST: submit jobs, list and triage observations, fetch cost forecasts, and inspect discovery output. This page covers the shape of the API. The endpoint reference lists the routes.
Basics
Every endpoint lives under /api/..., and requests and responses are JSON. Repositories are addressed by owner/repo/branch — for example, the jobs collection for a repo is /api/repos/acme/billing-service/main/jobs. Observations are named by their HYRAX-N ref. The same naming applies in the MCP server.
Two more ways to read the same surface, both generated from the running API so neither can drift from it: the API explorer renders every operation with its full request and response schemas, and the raw OpenAPI 3.1 document — served at /openapi.json on the public docs site, and downloadable from the explorer — feeds a client generator, a Postman import, or your own agent. Both are regenerated from the API on every change, with CI failing the build if they drift. (They live on the docs site, not the API host: api.hyrax.dev/api/openapi.json is staff-gated — an unauthenticated request gets a 401, and a key from a workspace without the entitlement gets a 402 plan_restricted.)
Path parameters are named github_org / github_repo / github_base_branch in the generated document; this page and the endpoint reference call the same three segments owner/repo/branch for readability.
Authentication
Authenticate every request with an API key in the Authorization header:
Authorization: Bearer hk_live_xxxxxxxxxxxxxxxxxxxxxxxx
Creating a key
Create keys in your workspace settings (POST /api/keys). The full key is shown exactly once at creation — store it somewhere safe, you can't retrieve it again. A key acts only within the workspace that created it, and only within a subset of its creator's permissions. Creating, editing, and revoking keys requires a workspace admin (or the account owner) signed in through the web app — the key-management surface can't be driven with a key itself.
Per-key rate and spend ceilings
POST /api/keys also accepts two optional per-key ceilings: requests_per_minute (integer ≥ 1) and dollars_per_hour (decimal > 0). Both compose as additional restrictions below your workspace limits — they can only tighten, never loosen — and omitting one means no per-key override on that axis. After mint, PATCH /api/keys/{key_id} is tighten-only for these fields: setting a ceiling where none existed or lowering an existing one is allowed; raising or clearing (explicit null) is rejected with a 400 — mint a new key instead (same rule as scope narrowing).
Expiry
Keys can also carry an optional expires_at, set at mint (any timestamp in the future). Expiry is immutable after mint — to extend a key's life, mint a replacement and revoke the old one, the same rule as loosening a ceiling. An expired key simply stops authenticating. The key's creator gets a warning email 7 days before expiry; keys minted with less than 7 days of life get none.
IP allowlists
Keys can carry an optional IP allowlist: allowed_cidrs, a list of CIDR ranges or bare hosts ("203.0.113.0/24", "198.51.100.7"; IPv4 and IPv6 both work). When set, the key only authenticates from a listed range — a request from any other network fails with a 401, and the check is fail-closed: if the client IP can't be reliably established, a fenced key is refused rather than admitted. The allowlist is enforced identically on the REST API and the MCP server — one fence, both transports. Like expiry, it is immutable after mint: to widen or drop the restriction, mint a replacement key and revoke the old one. Omit the field for no restriction.
One operational note for CI: GitHub-hosted runner egress IPs are broad and change constantly, so fencing a CI key to GitHub-hosted runners is impractical — you would be chasing an ever-shifting range list. Self-hosted runners (or any fixed egress, such as a NAT gateway) with static IPs are the supported shape for an allowlisted CI key.
Suspended workspaces
API keys work only while the workspace is active. If your workspace is suspended (for example after a subscription lapse), every key-authenticated request fails with a 401 until you resubscribe (on the REST API the error message names the suspended state; the MCP server returns a generic 401) — the 30-day read-only grace window applies to signing in on the web app, not to API keys. Your keys themselves are untouched: the same keys work again the moment the workspace is active.
API keys — and the MCP server, which uses the same keys — are included on Pro and Team. A key-authenticated request from a workspace without API access fails with a 402 plan_restricted error (see the envelope below) carrying required_features: ["api_keys"], rather than a 401 — the key is valid; the workspace entitlement is what's missing.
Errors
Every error response uses a single envelope, so you can branch on it the same way everywhere. type is the broad bucket; code is the specific, machine-branchable slug.
{
"error": {
"type": "validation_error",
"code": "invalid_payload",
"message": "Unknown workflow 'benchmark'.",
"request_id": "req_..."
}
}
type | HTTP | When |
|---|---|---|
validation_error | 422 | The request body or query parameters failed validation. Always code: invalid_payload. A schema-level failure also carries a per-field errors array; a semantic refusal — a params object the workflow itself rejects, say — puts the same detail in message and omits errors entirely, so read message when the array is absent. |
invalid_request_error | 400, 413, 422 | The request was malformed, or the resource is in a state that can't satisfy it — codes like bad_request, invalid_cursor, no_ticket_integration, observation_not_fixable, observation_fix_targets_workflow_file (the finding lives under .github/workflows/, which Hyrax's GitHub App can't push to — apply it by hand), and payload_too_large (that one is always a 413). Also the fallback bucket for 405/415 and any otherwise-unmapped 4xx. |
authentication_error | 401 | Missing, malformed, expired or revoked key — also a key used from a network its IP allowlist excludes, or against a workspace that isn't active. |
permission_error | 403 | The key is valid but lacks permission for this action. |
not_found | 404 | The resource doesn't exist or isn't visible to you. |
billing_error | 402 | A billing or plan gate blocked the request. |
rate_limit_error | 429 | A rate limit was hit. Carries a Retry-After header. |
conflict | 409 | A state precondition failed. |
server_error | 500, 503 | Something went wrong on Hyrax's side, or a dependency is briefly unavailable. Most 503s carry a Retry-After header; honour it when it's there, and back off on your own schedule when it isn't. |
A billing_error carries required_features when a feature gate refused (for example ["api_keys"]), and details.allowed_workflows when the workflow you asked for isn't in your plan. Branch on code, not on the status code — a 422 can be either bucket, and a 503 shares type with a 500. The codes are stable slugs (invalid_payload, invalid_cursor, plan_restricted, rate_limit_qps, rate_limit_budget, and so on).
Pagination
List endpoints return a cursor-paginated envelope:
{
"data": [ /* ... */ ],
"next_cursor": "eyJpZCI6ICIuLi4ifQ==",
"has_more": true
}
next_cursor is opaque — pass it back verbatim as the cursor query parameter to fetch the next page, and never build one yourself. A cursor is bound to the filter set that produced it: passing it back with any filter changed, or reusing it on a different endpoint, returns 400 invalid_cursor — restart from page one. Don't persist a cursor: treat it as good for the next page of the walk you're on, not as a durable handle to a position. It is null and has_more is false on the last page. Control page size with limit; the default varies by endpoint (50 on most lists, 100 on observations), and the max is 200 everywhere — a larger value is rejected with a validation_error, not truncated.
curl -s -G "https://api.hyrax.dev/api/observations" \
-d cursor=eyJpZCI6ICIuLi4ifQ== \
-H "Authorization: Bearer hk_live_..."
Rate limits
Each API key gets its own request budget, sized by your plan — roughly 600 requests/minute on Free, 1,200 on Pro, 3,000 on Team. Two keys don't share a counter, so a workspace's total throughput scales with how many keys it holds (up to 25 live at a time). If you want a key to run below that, set requests_per_minute on it at mint.
Individual paths sit in their own, much tighter buckets — still per key, and scoped to a bucket and a method, so a route's read and its write are counted separately. Several paths can share one bucket; the rows below mark those as combined:
| Path | Limit |
|---|---|
POST /api/repos | 10/min |
GET /api/repos | 60/min |
POST /api/repos/{owner}/{repo}/{branch}/jobs, POST /api/jobs/{job_id}/retry | 30/min combined |
GET /api/repos/{owner}/{repo}/{branch}/jobs | 120/min |
POST /api/repos/{owner}/{repo}/{branch}/reset | 5/min |
POST /api/observations/{ref}/fix | 30/min |
POST /api/observations/{ref}/publish-ticket | 30/min |
POST /api/observations/{ref}/dismiss, /reopen, /complete, /acknowledge | 60/min combined |
Everything else — the workspace-wide job list and a single job's detail, the observation reads, the repo and forecast reads, cancelling a job, removing a repo — is bounded only by the per-key budget above.
Four of those paths also draw on your workspace's hourly spend ceiling, because each one can start paid work: registering a repo (it auto-enqueues a discovery run), submitting a job, retrying a job, and spawning a fix. On those four a 429 can arrive with code: "rate_limit_budget" instead of rate_limit_qps — you're inside the request limit but the workspace is burning budget too fast. Plain reads never consume it.
Operations with a path-specific bucket publish their numbers as an x-rate-limit extension in the OpenAPI document, so a generated client can read them; an operation with no extension is on the per-key budget. The published rpm is that path's own ceiling — the limit you actually hit is the lowest of it, your plan's per-key budget, and any requests_per_minute set on the key. When you exceed a limit you get 429 rate_limit_error with a Retry-After header. Back off for that many seconds, then retry.
Compatibility
The API only ever changes additively: new response fields, new endpoints, new MCP tools. Build your integration to tolerate fields it doesn't recognize, and it won't break.
One thing to never build against: pagination cursors (opaque — pass them back verbatim, never inspect or construct one). Renames, removals, and type changes don't happen out from under you — if a breaking change is ever needed on a generally-available surface it ships on a new versioned path, and while a feature is in private beta (chat, webhooks, campaigns, task, suggest) we coordinate any change directly with the workspaces that have it.
A minimal example
Submit a standard_audit job for acme/billing-service at main:
curl -s -X POST \
"https://api.hyrax.dev/api/repos/acme/billing-service/main/jobs" \
-H "Authorization: Bearer hk_live_..." \
-H "Content-Type: application/json" \
-d '{
"workflow": "standard_audit",
"params": {}
}'
A successful submission returns the new job. Its status moves through pending → running → one of completed / failed / cancelled.
The audit comes in three depths: mini_audit (Free), standard_audit (Pro and Team), and the full audit (Team). Submitting a depth your plan doesn't include returns 402 billing_error.
{
"id": "...",
"workflow": "audit",
"status": "pending",
"repo_name": "billing-service@main"
}
See also
- Endpoint reference — every route, grouped by resource, with examples.
- MCP server — query live discovery and findings state from an AI coding agent, with the same API key.
- Integrations — GitHub and Linear.
- Workflows — the job verbs you submit through the API.