API reference
Call the shiftlabs API from a script, CI or an AI agent with an API key.
API
All routes are under https://api.shiftlabs.org/v1 and act on your workspace.
The credential determines the workspace. Another workspace's trigger or run returns 404. Send an API key as Authorization: Bearer slk_…. Bodies are JSON (16 KiB at most, except a deploy's source and a run's payload), and errors are {"error": "…"} with a 4xx or 5xx status.
A re-run starts a new run with the original payload and counts against your run quotas. If the payload is older than 14 days, the re-run returns 410. When a quota is full, the API returns {"error": "quota exceeded", "limit", "max"} with 429 for runs, and 403 for counts such as workflows, triggers, secrets, connections and API keys.
To retry a run start safely, send an Idempotency-Key header: any unique string of 1 to 255 visible ASCII characters, such as a UUID. A repeat with the same key within 24 hours starts nothing and returns the first run's 202 {"runId", "versionHash"} again, with Idempotent-Replayed: true. It does not count against your run quotas, even when they are full. The same key on another workflow returns 409. The shiftlabs run command sends a key with each run and retries with it.
| Route | Method | Minimum role |
|---|---|---|
/v1/me principal, workspace, limits | GET | viewer |
/v1/workspace {"name"}, 1 to 64 characters; audited | PATCH | admin |
/v1/usage runs and egress in the last 24 h, counts, limits | GET | viewer |
/v1/workflows | GET | viewer |
/v1/workflows/:workflow body: the source | PUT | developer |
/v1/workflows/:workflow revokes its triggers, drops its vars and connection links; runs stay | DELETE | developer |
/v1/workflows/:workflow/versions | GET | viewer |
/v1/workflows/:workflow/versions/:hash/source {hash, language, source}; audited | GET | developer |
/v1/workflows/:workflow/runs body: the payload, read like a webhook's, 256 KiB at most | POST | developer |
/v1/workflows/:workflow/rollback {"hash"}: new runs use that version | POST | developer |
/v1/deploys · /v1/deploys/:id deploys that waited on Python packages; ?status=building|live|failed | GET | viewer |
/v1/workflows/:workflow/connections {"connections": [...]} | GET · PUT | viewer · developer |
/v1/workflows/:workflow/vars/:NAME {"value"} | PUT · DELETE | developer |
/v1/triggers {"workflow", "signing"?: {"scheme", "secret"?}} | GET · POST | viewer · developer |
/v1/triggers/:id/rotate | POST | developer |
/v1/triggers/:id {"signing": {"scheme", "secret"?} | null}; the URL stays | PATCH | developer |
/v1/triggers/:id | DELETE | developer |
/v1/runs?status=&workflow=&origin=&limit=&before= newest first, cursor in nextBefore; each run's origin is trigger, rerun, dashboard, api or mcp, and origin_ref names who started it | GET | viewer |
/v1/runs/:id · /logs?limit= · /timeline · /egress | GET | viewer |
/v1/runs/:id/rerun same workflow and version, new run | POST | developer |
/v1/runs/:id/payload the delivered body; 410 after 14 days; audited | GET | developer |
/v1/secrets metadata, never values | GET | viewer |
/v1/secrets/:NAME {"value", "hosts"?, "readable"?, "hmac"?} | PUT · DELETE | admin |
/v1/secrets/:NAME {"hosts"?, "readable"?, "hmac"?}; the value and its version stay | PATCH | admin |
/v1/connections | GET | viewer |
/v1/connections/:name {"hosts", "template"} | PUT · DELETE | admin |
/v1/vars | GET | viewer |
/v1/vars/:NAME {"value"} | PUT · DELETE | developer |
/v1/audit?limit= | GET | admin |
/v1/metrics/runs?hours= · /v1/metrics/egress?hours= | GET | viewer |
/v1/api-keys {"name", "role", "capabilities"?, "scope"?, "expiresInDays"?} | GET · POST | admin |
/v1/api-keys/:id | DELETE | admin |
/v1/members | GET | admin |
/v1/members/invitations {"email", "role"} | POST | admin |
/v1/members/:membershipId {"role"} | PATCH · DELETE | admin |
/v1/alerts {"emails", "webhookUrl", "workflows", "refusals"}; the answer that makes the webhook's secret has signingSecret once | GET · PUT | admin |
/v1/alerts/test | POST | admin |
/v1/alerts/rotate-secret a new signingSecret, shown once; the old one stops working | POST | admin |
Roles
Roles rank viewer < developer < admin. Each role can do everything the roles below it can. Roles work the same for API keys and for members signed in to the dashboard.
- viewer
Read workflows, versions, runs, logs, timelines, egress, metrics, usage, vars, secret metadata (no values), and connections with their hosts and templates. Trigger ids are masked and URLs are hidden.
- developer
Deploy, run, roll back and delete workflows and read their deployed source, set a workflow's connections and vars, set workspace vars, create, rotate and revoke triggers or change their signing, and re-run runs.
- admin
Set secrets (or change only their hosts and flags) and connections, read the audit log, manage API keys, and invite, re-role and remove members.
Members · ridgeline-ops
3| Member | Role |
|---|---|
| dana@ridgeline.example | admin |
| li@ridgeline.example | developer |
| sam@ridgeline.example | viewer |
The last active admin cannot be demoted or removed.
Each route needs one capability, such as workflow.deploy or run.view, and each role holds the capabilities at or below it. A caller without the route's capability gets 403 {"error": "forbidden", "required": "<minimum role>", "capability": "<name>"}. GET /v1/me lists the caller's capabilities. The last active admin cannot be demoted or removed (409). A suspended workspace can still read and revoke triggers, API keys and members. Any other write returns 403 {"error": "tenant suspended"}.
API keys
An API key is shown only once, when an admin creates it. We store only its SHA-256 hash.
Key created · deploy-bot
developerslk_7Qh2mZ0vYp4cN8xW1sR6tL3kJ9bF5dG0aE2uI7oP4yT
- Id
key_3f9a0c7e21b4d856- Prefix
slk_7Qh2mZ0v- Expires
- 2027-01-18, in 90 days
Copy the key now. It's shown only once.
- An admin creates a key with
POST /v1/api-keys {"name", "role", "capabilities"?, "scope"?, "expiresInDays"?}. The response is{id, key, prefix, role, capabilities, scope, expiresAt}. Thekeyis shown only once. - A key is 256 random bits, belongs to one workspace and one role, and expires after
expiresInDays(1–365, default 90).capabilitiesnarrows it to some of what its role allows, for example["workflow.deploy"]for a CI key.scopelimits it to named workflows, for example{"workflows": ["billing"]}: lists show only those, their runs and triggers, and every other route refuses the key. A key never reaches further than the key or person that created it.
- A workspace holds at most 20 active keys.
GET /v1/api-keyslists metadata only, andlast_used_atupdates at most once a minute. Revoke a key withDELETE /v1/api-keys/:id. - Failed authentication is rate limited per IP. After 10 failures in a minute, the API returns
429.
Rate limits
Over a limit, a request gets 429 and a Retry-After header with the seconds to wait. Request rates are the same on every plan.
| Requests | Limit | Counted | Retry-After |
|---|---|---|---|
| Any request to the API, webhooks or dashboard | 600 a minute | per IP | 60 (webhooks: 10) |
| Failed authentication: a bad API key or a failed sign-in | 10 a minute | per IP | 60 |
/v1 calls, from API keys, MCP tool calls and the dashboard | 1,200 a minute | per workspace | 60 |
Run logs, timelines and egress, and /v1/metrics a read repeated within 10 seconds is served from cache and not counted | 120 a minute | per workspace | 60 |
Deploys, PUT /v1/workflows/:workflow | 20 a minute | per workspace | 60 |
| Member invitations | 5 a minute | per workspace | 60 |
Test alerts, POST /v1/alerts/test | 1 a minute | per workspace | 60 |
| Webhook deliveries to one trigger | 100 per 10 seconds | per trigger | 10 |
| Webhook deliveries to all of a workspace's triggers | 300 per 10 seconds | per workspace | 10 |
| Any request to shiftlabs.org or its subdomains the edge blocks the IP for 10 seconds and answers for us, so the body is not our JSON | 150 per 10 seconds | per IP | wait 10 seconds |
- Request rates are counted per edge location, so they are approximate. A rate-limited request returns
{"error": "rate limited"}. Test alerts are the exception: they are counted exactly, per workspace, and a second one within a minute returns{"error": "one test a minute"}. - No response carries
RateLimitorX-RateLimitheaders.Retry-Afteris the signal: wait that many seconds, then retry. Clients that share a budget should add some random delay. - A full quota also returns
429, with{"error": "quota exceeded", "limit", "max"}. That covers runs per 24 hours and runs at once, new versions per 24 hours (100) and member invitations per 24 hours (20 on the free tier). ItsRetry-Afteris the time until the oldest one ages out, or for runs at once, until the next check, at most 30 seconds. Pricing lists the quotas. - Python package builds answer
429withoutRetry-After: a workspace can start 20 new package builds in 24 hours, and have 5 deploys waiting on builds at once. Wait for a build to finish atGET /v1/deploys/:id. - Over MCP, a refused tool call returns an error result that starts with
HTTP 429. The header is not passed on, so wait a minute before calling again. - Requests your workflows send have their own limits: 30 a minute to one host, and an outbound quota per 24 hours. See At the gateway.
MCP
AI agents work with your workspace through the Model Context Protocol at https://api.shiftlabs.org/mcp, with an API key.
// In your MCP client's server list (streamable HTTP).
{ "shiftlabs": {
"type": "http",
"url": "https://api.shiftlabs.org/mcp",
"headers": { "Authorization": "Bearer slk_…" } } }
# The tools a key can call.
curl -X POST https://api.shiftlabs.org/mcp \
-H "authorization: Bearer $SHIFTLABS_API_KEY" -H "content-type: application/json" \
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'
- Each tool call makes the matching
/v1request with the same key. The key's role, your limits and the audit log apply as they do for curl. A viewer key lists only read tools. - Read:
list_workflows,list_versions,list_runs,get_run,get_run_logs,get_run_timeline,list_triggers,list_secrets,list_connections,get_usage. Developer:get_workflow_source,deploy_workflow,run_workflow,rerun,create_trigger. run_workflowtakes an optionalidempotencyKey. Calling it again with the same key within 24 hours returns the first run instead of starting another.- No tool returns secret values.
list_secretsreturns names, hosts and flags, and connections hold expressions only. Give an agent a key with the lowest role it needs and a short expiry.
Alert webhooks
Failure alerts post JSON to the webhook you set with PUT /v1/alerts. Each post is signed with your workspace's secret, so your endpoint can check that it came from shiftlabs.
// secret: the signingSecret. header: the Shiftlabs-Signature header.
// body: the raw request body as a string, before any JSON parsing.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, header, body) {
const parts = Object.fromEntries(header.split(",").map((part) => part.trim().split("=")));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${body}`).digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}
- Each post carries
Shiftlabs-Signature: t=<unix seconds>,v1=<hex>, wherev1is HMAC-SHA256 of<t>.<body>keyed with the whole secret,whsec_included. It is the header a trigger withshiftlabssigning checks. Reject atmore than 5 minutes off. - The secret is made when you save a webhook and the workspace has none. It is returned once, as
signingSecretin that answer, andGET /v1/alertsnever returns it.POST /v1/alerts/rotate-secretreturns a new one, and the old one stops working at once. Removing the webhook deletes the secret. - A 5xx, a
429, a timeout (10 s) or a network error is retried 1, 5, 15 and 60 minutes later. The fifth attempt is the last, and any other answer is final. Every attempt sends the same body with the sameid, so you can drop repeats, and is signed when it is sent. GET /v1/alertslists the last 20 attempts indeliveries, newest first, each withat,id,kind,attempt,statusorerror,finalandretryAt.
Versioning
Every route is under /v1. Within v1, changes only add.
- What we add
New routes, new optional fields in requests, new fields in responses, and new values in lists of values, such as a run's
origin. Ignore fields you don't know, and accept values you haven't seen. - Breaking changes
Removing or renaming a route or field, changing a type or a status code, or making an optional field required comes in a new version, such as
/v2.v1keeps working beside it until it is deprecated as below. - Deprecation
Anything we will remove is announced on the changelog. Its responses carry
DeprecationandSunsetheaders at least 90 days before it is removed.Sunsetgives the date it stops working.