Public API
One GraphQL endpoint for everything the dashboard, the CLI and the MCP server do. Token types, rate limits, common operations and Railway compatibility.
POST https://api.skiffly.dev/graphql (alias: /graphql/v2)
Authorization: Bearer <token>
Content-Type: application/jsonGraphiQL is available at the same URL in a browser. The schema follows the Railway Public API: Relay connections, input objects, DeploymentStatus in upper case, variables as a map, so documents written for Railway usually run unchanged.
Tokens#
| Token | Header | Scope | Create |
|---|---|---|---|
| Account | Authorization: Bearer <session token> | every workspace of the user | skiffly login stores one |
| Workspace | Authorization: Bearer skiffly_… | one workspace; read or write scope | Settings → Developer → Create token, or apiTokenCreate(input: { name, workspaceId, scopes }) |
| Project | Project-Access-Token: skiffly_project_… | one environment of one project: deploy, variables, logs, domains, volumes | projectTokenCreate(input: { projectId, environmentId, name }) |
Tokens are shown once. Project tokens are the right choice for CI: they cannot see other projects or billing. Anything outside the token's scope returns FORBIDDEN.
Rate limits per workspace plan: Free 100 requests/hour; Hobby 1 000/hour and 10/second; Pro 10 000/hour and 50/second. Responses carry X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset; a 429 includes Retry-After.
A shell helper#
export SKIFFLY_TOKEN=skiffly_...
gql() { curl -s https://api.skiffly.dev/graphql -H "Authorization: Bearer $SKIFFLY_TOKEN" -H 'content-type: application/json' \
--data-binary "$(node -e 'const [q,v]=process.argv.slice(1);process.stdout.write(JSON.stringify({query:q,variables:JSON.parse(v||"{}")}))' "$1" "$2")"; echo; }
gql '{ me { login workspaces { id name role } } }'The CLI does the same with skiffly api '<query>' -v '<json>' using its saved token.
Walkthrough: project → service → domain#
gql 'mutation($ws:String!){ projectCreate(input:{workspaceId:$ws, name:"wevo"}){ id environments { edges { node { id name } } } } }' '{"ws":"<workspaceId>"}'
# repository (Railpack, or the Dockerfile in the root)
gql 'mutation{ serviceCreate(input:{projectId:"<projectId>", name:"web", source:{repo:"acme/web"}, branch:"main"}){ id } }'
# image, with variables
gql 'mutation{ serviceCreate(input:{projectId:"<projectId>", name:"redis", source:{image:"redis:7"}, port:6379, variables:{ REDIS_ARGS:"--save 60 1" }}){ id } }'
# variables (serviceId null = shared); values may reference other services
gql 'mutation{ variableCollectionUpsert(input:{projectId:"<projectId>", environmentId:"<envId>", serviceId:"<serviceId>", variables:{ REDIS_URL:"${{redis.REDIS_URL}}" }}) }'
gql '{ variables(projectId:"<projectId>", environmentId:"<envId>", serviceId:"<serviceId>") }'
# domains
gql 'mutation{ serviceDomainCreate(input:{serviceId:"<serviceId>", environmentId:"<envId>"}){ domain } }'
gql 'mutation{ customDomainCreate(input:{projectId:"<projectId>", serviceId:"<serviceId>", environmentId:"<envId>", domain:"www.example.com"}){ id status { dnsRecords { recordType fqdn requiredValue } } } }'
gql 'mutation{ customDomainVerify(id:"<domainId>"){ status { verified } } }'
# volume, settings, deploy, logs
gql 'mutation{ volumeCreate(input:{projectId:"<projectId>", serviceId:"<serviceId>", environmentId:"<envId>", mountPath:"/data", sizeGb:5}){ id } }'
gql 'mutation{ serviceInstanceUpdate(serviceId:"<serviceId>", environmentId:"<envId>", input:{ memoryBytes:1073741824, cpuMillis:1000, healthcheckPath:"/healthz", numReplicas:1 }) }'
gql 'mutation{ serviceInstanceDeployV2(serviceId:"<serviceId>", environmentId:"<envId>") }'
gql '{ deployments(input:{serviceId:"<serviceId>", environmentId:"<envId>"}, first:3){ edges { node { id status message createdAt } } } }'
gql '{ deploymentLogs(deploymentId:"<deploymentId>", limit:200){ timestamp severity message } }'serviceInstanceDeployV2 returns the deployment id; poll deployment(id) { status message } until SUCCESS or FAILED.
Errors#
GraphQL errors carry extensions.code: UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, BAD_USER_INPUT, CONFLICT, PLAN_LIMIT (with the limit that was hit), TEMPLATE_VARIABLES_REQUIRED (with extensions.missing), RATE_LIMITED.
Railway compatibility#
Same names and shapes where the semantics match: project, projects, environment(s), service, serviceInstance, deployment(s), deploymentLogs, environmentLogs, variables, domains, customDomain*, serviceDomain*, volume*, projectToken*, apiToken*, templates, template, templateDeployV2, serviceInstanceUpdate, serviceInstanceDeployV2, serviceInstanceRedeploy, variableUpsert, variableCollectionUpsert, variableDelete, deploymentCancel, deploymentRedeploy, deploymentRestart, deploymentRollback, tcpProxy*. Arguments are ID! but documents declaring $id: String! are accepted.
Differences that matter:
| Railway | Skiffly |
|---|---|
buildLogs and deploymentLogs | one deploymentLogs; Log.stream is build, runtime or system |
deploymentRestart(id) | also serviceRestart(serviceId, environmentId) |
serviceConnect to attach a source | serviceUpdate(id, { source }) |
| resources are plan-level | serviceInstanceUpdate takes cpuMillis / memoryBytes |
Workspace.plan object | Plan enum FREE / HOBBY / PRO |
| Subscriptions for logs | HTTP only; poll deploymentLogs |
Staged changes (environmentStageChanges, environmentPatchCommit*) | none: changes apply immediately, redeploy when needed |
| Regions, egress gateways, private networks, buckets, functions, sandboxes, teams/RBAC, feature flags, webhooks | not available |
Skiffly-only additions: billingAccount, ledger, payments, topupCreate, usage, serviceMetrics, nodes, githubInstallations, githubApp, tcpProxies, Service.internalHost, ServiceInstance.branch, ServiceUpdateInput.runtimeClass.
The full SDL is served by introspection at the endpoint; the GraphQL reference lists every root field.