API overview
Base URL, versioning, response envelopes, rate limits, and the transport rules the API enforces.
Base URL and versioning
https://shortspan.dev/api/v1/The version is in the path. A breaking change gets a new version rather than a silent alteration of this one; within v1, endpoints and fields may be added but existing ones will not change shape.
This is the same surface the CLI uses. Anything the CLI can do, your own tooling can do - and the CLI is a reasonable reference implementation when a detail here is ambiguous.
Transport
TLS is required. A plaintext request is refused with 426 Upgrade Required and code HTTPS_REQUIRED - it is not redirected, because a redirect means the credential was already sent in the clear.
Loopback is exempt so a local development deployment works.
Client version header
X-Shortspan-CLI-Version: 1.4.2The CLI sends this on every request. It is what lets a release with a security defect be withdrawn centrally: a blocked version is refused with 403 CLI_VERSION_BLOCKED. Your own client need not send it, and is never blocked by it.
Response shape
Success responses carry a data key. A single resource is an object; a collection is an array.
{
"data": {
"id": "9f2c1e04-7b3a-4d51-8e6f-2a9c4b1d7e30",
"key": "DATABASE_URL",
"type": "env",
"format": "string",
"template": "env",
"metadata": null,
"created_at": "2026-08-14T09:31:02.000000Z",
"updated_at": "2026-08-27T16:02:44.000000Z"
}
}Errors carry an error object with a stable code and a human message. Some errors add fields - a paywall response names the limit and the plan that lifts it.
{
"error": {
"code": "PLAN_LIMIT_REACHED",
"message": "Your Free plan allows 3 projects. Upgrade to add more.",
"limit_key": "projects",
"limit": 3,
"current_plan": "free",
"required_plan": "pro"
}
}Ids are UUID strings throughout - including token ids, which Sanctum's parent model incorrectly types as integers. Treat every id as a string.
Status codes
| Status | Means |
|---|---|
| Success. |
| Created. |
| Success, no body - deletions. |
| Malformed request. Also the device flow's |
| Missing, invalid, or revoked token. |
| The workspace's plan does not include this. The body names the required tier. |
| Authenticated but not permitted - role, ability, suspension, or a blocked CLI version. |
| No such resource, or none you can see. The two are deliberately indistinguishable. |
| Wrong method for this path. |
| Onboarding is not finished, so there is no workspace to act in. |
| Validation failed. The body carries an |
| Not TLS. |
| Rate limited. |
| Our fault. Report it. |
Rate limits
| Limiter | Budget | Keyed by |
|---|---|---|
General API | 300/minute | Token, or IP when unauthenticated. |
Credential reveal | 60/minute | Token. Applies to every endpoint that returns a decrypted value. |
Device code | 20/minute | IP. |
Device token poll | 30/minute per code, 240/minute per IP | The device code, and the IP. |
The reveal limiter covers secret reveal and injection, SSH and database credential issue, AWS injection, and kubeconfig fetch - every path that produces plaintext. Reading secrets in bulk is what a stolen token looks like, so it is bounded separately and much more tightly than everything else.
Pagination and filtering
Collections scoped to a project - secrets, targets, runbooks - return in full; a project holding enough of them to need pages is a signal to split the project. The audit log is paginated, since it grows without bound.
Filters are query parameters where they exist, for example ?group=api-production when listing secrets.
Something here wrong or missing? Tell us.