HTTP API reference
The control-plane API lives at https://api.slew.cloud. It's the same API the slew CLI and the dashboard use — everything they do, you can do with curl.
Authentication
All endpoints except /health and /auth/* require a CLI token:
curl https://api.slew.cloud/me \
-H "Authorization: Bearer slew_..."
Tokens come in kinds and are not interchangeable:
| Kind | Owned by | Works against |
|---|---|---|
cli |
you | This API (api.slew.cloud) |
ai |
you or an org | The AI gateway (ai.slew.cloud) only |
deploy |
an org | The deploy surface of this API only (see Organizations) |
Get a cli token by running slew login, or mint extra ones with slew token cli "browser" or POST /tokens. Org-owned tokens are minted under /orgs/:name/tokens; a deploy token reaches exactly: GET /me, GET /projects, GET /projects/:name, upload, and activate — everything else answers 401. Secrets are the format slew_ + 43 URL-safe characters, shown exactly once — slew stores only a SHA-256 hash. Missing, invalid, revoked, or wrong-kind tokens all return 401 unauthorized.
For CI, prefer an org deploy token over a personal cli token: it can't touch anything but deploys, and it keeps working when the person who minted it leaves the org.
Errors
Every error is JSON in one shape:
{ "error": { "code": "not_found", "message": "Not found" } }
Unknown paths are 404 not_found; unexpected server problems are 500 internal.
Endpoints
Health & identity
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/health |
none | { "ok": true }, or 503 when the platform is unhealthy |
GET |
/me |
yes | Your account: id, github_id, github_login, email, token_kind, org (the org's name for org-owned tokens, else null), created_at |
Device-flow login — /auth
The CLI's slew login uses these; you only need them if you're building your own tooling.
| Method | Path | Rate limit | Description |
|---|---|---|---|
POST |
/auth/device |
10 / 10 min per IP | Start a GitHub device flow |
POST |
/auth/device/poll |
180 / 10 min per IP | Poll until authorized |
POST /auth/device returns GitHub's device-flow payload (device_code, user_code, verification_uri, expires_in, interval). Poll with { "device_code": "…" } until you get { "token": "slew_…" }. While waiting, the poll returns standard device-flow error codes:
| State | Status | error.code |
|---|---|---|
| Still waiting | 400 | authorization_pending |
| Polling too fast | 429 | slow_down |
| Code expired | 400 | expired_token |
| You denied on GitHub | 403 | access_denied |
Tokens — /tokens
| Method | Path | Description |
|---|---|---|
GET |
/tokens |
List your tokens, newest first |
POST |
/tokens |
Create a token |
DELETE |
/tokens/:id |
Revoke a token; 404 if unknown or already revoked |
POST /tokens body: { "kind": "cli" | "ai", "label": "optional, ≤ 200 chars" }. The 201 response is the token record plus token, the plaintext secret — returned in this response only. Token records look like { id, kind, label, created_at, revoked_at }.
Projects — /projects
Project routes only ever see projects you can act on — your own, or those of organizations you belong to; anyone else's are a 404.
| Method | Path | Description |
|---|---|---|
GET |
/projects |
List your personal projects, newest first; ?org=<name> lists an org's instead |
POST |
/projects |
Create a project (add "org": "<name>" to create it in an org) |
GET |
/projects/:name |
Project detail + 20 most recent deployments + custom domains |
POST |
/projects/:name/deployments |
Upload a tar.gz and go live |
POST |
/projects/:name/deployments/:id/activate |
Roll back / re-promote a deployment |
DELETE |
/projects/:name/deployments/:id |
Delete an inactive deployment and its files |
POST |
/projects/:name/domains |
Attach a custom domain |
DELETE |
/projects/:name/domains/:domain |
Detach a custom domain |
GET |
/projects/:name/domains/:domain/certificate |
Live TLS certificate status for a custom domain |
POST |
/projects/:name/domains/:domain/certificate/refresh |
Ask the CDN to (re)issue the free certificate |
PUT |
/projects/:name/access |
Who may view the site: { "mode": "public" | "password" | "org", "password"? } — see access control |
PUT |
/projects/:name/password |
Shorthand for password mode: { "password": "…" } (4–128 chars) |
DELETE |
/projects/:name/password |
Make the site public again |
POST |
/projects/:name/transfer |
Move the project: { "org": "<name>" } into an org you belong to, { "org": null } back to personal |
GET |
/projects/:name/env |
List env vars (decrypted) with their scope |
PUT |
/projects/:name/env/:key |
Set a var: { "value": "…", "scope"?: "runtime" | "build" | "both" } — scope omitted keeps the stored one (default runtime) |
DELETE |
/projects/:name/env/:key |
Remove a var |
Creating a project — { "name": "my-site" }:
- Names are 3–40 characters of
a-z,0-9, and-— the name is your subdomain, so it's globally unique (409 name_takenif someone has it). - Reserved names (
www,api,ai,app,admin,dashboard,status,docs,mail) and thetmp-prefix are rejected.
{
"id": "prj_…", "name": "my-site", "org": null,
"url": "https://my-site.slew.cloud",
"active_deployment_id": "dep_…",
"expires_at": null,
"created_at": "2026-07-10T…"
}
org is the owning organization's name, or null for a personal project.
Uploading a deployment — POST the raw gzipped tarball of your built site:
tar -czf site.tar.gz -C dist .
curl -X POST "https://api.slew.cloud/projects/my-site/deployments" \
-H "Authorization: Bearer slew_..." \
-H "Content-Type: application/gzip" \
--data-binary @site.tar.gz
Requirements and limits:
| Rule | Limit / behavior |
|---|---|
Content-Type |
application/gzip or application/x-gzip, else 415 |
| Tarball size | ≤ 100 MB, else 413 payload_too_large |
| Files in the archive | ≤ 10,000 regular files (at least 1) |
| Single file | ≤ 25 MB extracted |
| Total extracted | ≤ 250 MB |
| Entry paths | No absolute paths, .. segments, backslashes, or NUL bytes — 400 invalid_archive |
| Directories & symlinks | Silently skipped; only regular files are stored |
Optional headers X-Slew-Cli-Version and X-Slew-Git-Sha are recorded with the deployment. A successful upload goes live immediately:
{ "deployment_id": "dep_…", "url": "https://my-site.slew.cloud", "expires_at": null }
Deployments are immutable — files are stored per deployment and never overwritten. That's what makes rollback instant: activation just flips a pointer.
Activate (rollback) — POST /projects/:name/deployments/:id/activate re-promotes any deployment whose status is live or superseded (anything else is 409 not_activatable). Returns { "ok": true, "active_deployment_id": "dep_…" }. Deployment statuses: uploading → live or failed; a replaced deployment becomes superseded.
Delete a deployment — DELETE /projects/:name/deployments/:id removes the deployment's stored files and its record. The live deployment can't be deleted (409 deployment_live) — activate another one first. Returns { "ok": true }. Deletion is permanent; a deleted deployment can't be rolled back to.
Custom domains — { "domain": "example.com" }. The domain must be a real hostname with at least two labels; slew.cloud subdomains are rejected (you already have one). A domain attached to another project is a 409 domain_taken. The 201 response tells you where to point DNS:
{ "domain": "example.com", "tls_ready": false, "dns_target": "…" }
Create a CNAME from your domain to dns_target; TLS is provisioned free once DNS resolves. Re-adding your own domain is idempotent and retries the TLS setup, so it's safe to call again after flipping DNS.
Certificate status — GET /projects/:name/domains/:domain/certificate probes what the domain actually serves on :443 right now and reports it:
{
"domain": "example.com",
"reachable": true, "valid": true,
"issuer": "Let's Encrypt R11", "subject": "example.com",
"valid_from": "2026-06-01T…", "valid_to": "2026-08-30T…",
"days_remaining": 50,
"error": null
}
valid is true only when the chain verifies, the hostname matches, and the current time is inside the validity window. An unreachable domain (DNS not pointed yet, nothing listening on 443) comes back with reachable: false and an error string.
Certificate refresh — POST /projects/:name/domains/:domain/certificate/refresh asks the CDN to (re)issue the free certificate and returns { "ok": true, "domain": "…", "tls_ready": true|false }. tls_ready: false means issuance is still pending — usually because DNS doesn't point at the CDN yet. Safe to repeat.
Traffic and audience stats
Every project's analytics — cookieless, measured at the CDN edge — over three read-only endpoints:
| Method | Path | Description |
|---|---|---|
GET |
/projects/:name/stats |
Live traffic, aggregated from the CDN's request logs (1–72 h) |
GET |
/projects/:name/stats/history |
Durable daily traffic series from the hourly rollups (1–365 days) |
GET |
/projects/:name/stats/audience |
Pageviews, unique visitors, top pages / referrers / countries (1–365 days) |
Live stats — GET /projects/:name/stats?hours=24. hours is a whole number from 1 to 72 (default 24; 72 is the CDN's raw-log retention), else 400 invalid_request. Aggregates the project's subdomain and its custom domains:
{
"project": "my-site",
"from": "2026-07-11T10:00:00.000Z", "to": "2026-07-12T10:00:00.000Z",
"totals": { "requests": 12405, "bytes": 1913820432, "cache_hits": 11933 },
"status": { "2xx": 12220, "3xx": 141, "4xx": 43, "5xx": 1, "other": 0 },
"cache": { "HIT": 11933, "MISS": 472 },
"hostnames": [
{ "hostname": "my-site.slew.cloud", "requests": 12001, "bytes": 1845101532 },
{ "hostname": "www.example.com", "requests": 404, "bytes": 68718900 }
],
"timeline": [ { "hour": "2026-07-11T10:00:00.000Z", "requests": 512, "bytes": 79833211, "cache_hits": 498 } ],
"top_paths": [ { "path": "/", "requests": 8911, "bytes": 901224410 } ],
"top_countries": [ { "country": "NL", "requests": 4102 } ],
"truncated": false
}
timelineis zero-filled per hour, so it charts directly;top_pathsholds up to 10 entries andtop_countriesup to 8.truncated: truemeans the window was busier than the per-request aggregation ceiling (50,000 log entries) and the numbers are a sample.- An environment without a CDN log source returns
503 stats_unavailable; a failing CDN log query is502 cdn_error.
History — GET /projects/:name/stats/history?days=30. days is a whole number from 1 to 365 (default 30), else 400 invalid_request. Whole UTC days including today, zero-filled:
{
"project": "my-site",
"from": "2026-06-12T00:00:00.000Z", "to": "2026-07-13T00:00:00.000Z", "days": 30,
"totals": { "requests": 302144, "bytes": 48022041133, "cache_hits": 288930 },
"timeline": [ { "day": "2026-06-12", "requests": 10230, "bytes": 1621100310, "cache_hits": 9902 } ]
}
Audience — GET /projects/:name/stats/audience?days=30, same days rules. Pageviews and cookieless unique visitors — see Analytics for what counts as a pageview and how visitors are counted:
{
"project": "my-site",
"from": "2026-06-12T00:00:00.000Z", "to": "2026-07-13T00:00:00.000Z", "days": 30,
"totals": { "pageviews": 48112 },
"timeline": [ { "day": "2026-06-12", "pageviews": 1604, "visitors": 512 } ],
"top_pages": [ { "path": "/", "views": 30211 } ],
"top_referrers": [ { "referrer": "news.ycombinator.com", "views": 4102 } ],
"top_countries": [ { "country": "NL", "views": 16044 } ]
}
visitorsis per-day only — daily uniques don't sum into a range total, so there is deliberately nototals.visitors.top_pagesandtop_referrershold up to 10 entries,top_countriesup to 8.
Organizations — /orgs
Shared workspaces; see Organizations for the model. Roles: member works on the org's projects, admin also deletes projects and manages members and invites, owner also changes roles and deletes the org. Org routes only ever see orgs you belong to (404 otherwise); insufficient role is a 403 forbidden.
| Method | Path | Description |
|---|---|---|
GET |
/orgs |
Your orgs with your role in each |
POST |
/orgs |
Create an org — { "name", "display_name?" }; you become its owner |
GET |
/orgs/:name |
Org detail: members (login, role, joined) and project count |
PATCH |
/orgs/:name |
Update the display name (admin+) |
DELETE |
/orgs/:name |
Delete the org (owner; 409 org_not_empty while it has projects) |
GET |
/orgs/:name/invites |
Pending invites (admin+) |
POST |
/orgs/:name/invites |
Invite by email — { "email", "role": "member"|"admin" } (admin+) |
DELETE |
/orgs/:name/invites/:id |
Revoke a pending invite (admin+) |
PUT |
/orgs/:name/members/:login |
Change a member's role — { "role" } (owner; 409 last_owner guards the final owner) |
DELETE |
/orgs/:name/members/:login |
Remove a member, or yourself to leave (admin+ for others; owners only removable by owners) |
GET |
/orgs/:name/icon |
The org's icon — no auth, it's embedded via <img>. Serves the uploaded image; else redirects to the GitHub avatar of the owner the org's linked repos live under, then of an account whose App installation is shared with the org; else 404 |
PUT |
/orgs/:name/icon |
Upload an icon: raw PNG/JPEG/WebP/GIF body, ≤ 512 KB (admin+) |
DELETE |
/orgs/:name/icon |
Remove the uploaded icon, restoring the GitHub-avatar fallback (admin+) |
Org names follow project-name rules (3–40 of a-z0-9-, globally unique, 409 name_taken). Org responses carry icon_url — the uploaded icon's path with a content-hashed ?v=, or null when the org relies on the fallback (or has no icon).
Invites. The 201 from creating an invite contains invite_url — the accept link, returned in this response only (the token behind it is stored hashed) — plus email_sent, false when the platform has no outbound email configured, in which case share the link yourself. Invites expire after 14 days and are single-use.
| Method | Path | Description |
|---|---|---|
GET |
/invites/:token |
Preview an invite: org, role, who sent it |
POST |
/invites/:token/accept |
Join the org at the invited role |
Org tokens — credentials owned by the org itself, so they survive their creator leaving:
| Method | Path | Description |
|---|---|---|
GET |
/orgs/:name/tokens |
Unrevoked org tokens with created_by (admin+) |
POST |
/orgs/:name/tokens |
Mint — { "kind": "ai"|"deploy", "label?" }; the 201 carries the secret once (admin+) |
DELETE |
/orgs/:name/tokens/:id |
Revoke (admin+) |
Shared GitHub installations — let org repo links survive the connecting member leaving:
| Method | Path | Description |
|---|---|---|
GET |
/orgs/:name/installations |
Installations shared with the org, with shared_by |
POST |
/orgs/:name/installations |
Share one of your own — { "installation_id" }; 409 shared_elsewhere if another org has it |
DELETE |
/orgs/:name/installations/:id |
Unshare (the sharer, or admin+) |
GET /github labels every installation with mine (boolean) and org (name or null), and POST /projects/:name/repo accepts installations shared with the project's org.
Org AI usage — GET /orgs/:name/usage/ai mirrors /usage/ai for requests made with org AI keys. The endpoint keeps attribution clear; the cost draws from the org's own plan allowance.
Org billing — an org is its own billing scope with its own plan, profile, and invoices. The surface mirrors the personal /billing routes; viewing is admin+, everything that touches money is owner-only:
| Method | Path | Description |
|---|---|---|
GET |
/orgs/:name/billing |
Plan, current-month usage, catalog, subscription, profile, invoices (admin+) |
PUT |
/orgs/:name/billing/profile |
Invoice details — the org's name, country, VAT number (owner) |
VAT numbers are checked against VIES, the EU's VAT registry: a number VIES doesn't know is rejected (400 vat_number_not_found). A valid EU VAT number outside the Netherlands gets the reverse charge on invoices.
| POST | /orgs/:name/billing/checkout | { "plan" } → { checkout_url } (owner) |
| POST | /orgs/:name/billing/change-plan | Immediate upgrade, new price at renewal (owner) |
| POST | /orgs/:name/billing/cancel · /resume | Cancel-at-period-end toggle (owner) |
| GET | /orgs/:name/billing/invoices/:id/pdf | { url } of the invoice PDF (admin+) |
Every paid invoice gets a proper invoice document: it is numbered, rendered as a PDF, and emailed to the billing contact automatically (for an org: an owner). Invoices listed on the billing overview carry has_document; when true, the pdf endpoint (GET /billing/invoices/:id/pdf for personal invoices) returns the download URL — also linked from the console's invoice table. Add your full address to the billing details to receive documents.
Deleting an org requires its plan to be cancelled and lapsed first (409 subscription_active), the same way it requires its projects to be gone.
Quotas. An org's projects, custom domains, server apps kept ready, bandwidth, and AI credit count against the org's own plan — never a member's, and creating orgs is capped by the creator's plan so free allowances can't be minted. Apps beyond the ready limit still work, but their first request after being idle may take longer. Members' personal allowances are unaffected by anything the org does.
Temporary shares — /shares
| Method | Path | Description |
|---|---|---|
POST |
/shares?ttl=7 |
One-shot preview deploy on a throwaway URL |
GET |
/shares |
Your live (unexpired) shares |
DELETE |
/shares/:name |
Delete a share now instead of waiting for expiry |
Same tarball upload as a deployment, but no project needed: slew creates an ephemeral project named tmp- + 10 random characters and returns its URL. ttl is 1–30 days (default 7). When the share expires, the URL stops serving immediately and the files are cleaned up.
Usage — /usage
| Method | Path | Description |
|---|---|---|
GET |
/usage/ai |
Your AI-gateway usage, aggregated per model |
{
"usage": [
{ "model": "slew/mistral-small", "requests": 12, "prompt_tokens": 3400, "completion_tokens": 900 }
],
"totals": { "requests": 12, "prompt_tokens": 3400, "completion_tokens": 900 }
}
Usage rows contain token counts and model names only — never prompt or completion contents.
Rate limiting
The unauthenticated /auth endpoints are rate-limited per IP (limits in the table above). Responses carry X-RateLimit-Limit and X-RateLimit-Remaining; going over returns 429 rate_limit_exceeded with a Retry-After header. The AI gateway has its own separate limits — see AI gateway.