Create a key, launch a server and react to events in a few minutes. Developer documentation is in English.
Everything you can do in the console you can do with the API: it is the same service underneath. Base URL: https://api.cloud.clk.no/v1. JSON in, JSON out, TLS only.
In the console, open API keys, pick a role (or scopes), a name and an optional expiry. The key is shown once.
export KEY=clk_live_…curl -H "Authorization: Bearer $KEY" https://api.cloud.clk.no/v1/me
curl -H "Authorization: Bearer $KEY" https://api.cloud.clk.no/v1/sizes
curl -H "Authorization: Bearer $KEY" https://api.cloud.clk.no/v1/imagesname, size and labels are required. network and sshKey can be left out when your organization has exactly one of each. Add your key with POST /v1/ssh-keys first.
curl -X POST https://api.cloud.clk.no/v1/servers \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "web-1",
"size": "CLK Dedicated Extra Small",
"labels": { "env": "prod" },
"userData": "#cloud-config\npackages:\n - docker.io\n"
}'
# 202 Accepted
{ "jobId": "0b9c…", "jobUrl": "/v1/jobs/0b9c…" }New accounts follow the same rules as in the console: the account must be approved, have a card, and stay within the starting limits.
Send an organization API key as a bearer token: Authorization: Bearer clk_live_…. A key belongs to one organization, carries scopes, can expire, and can be revoked in the console. Keys start with clk_live_ so secret scanners can recognise a leak. Only a hash is stored, so a lost key has to be replaced.
Giving an AI agent access? Create it its own key with the narrowest role, a short expiry, and caps: a maximum number of servers and a maximum monthly cost, set when you create the key in the console. A create that would break a cap is refused with 422 key_limit_exceeded. Keys cannot change billing, members or other keys.
| Role | Scopes |
|---|---|
| read-only | servers, ssh-keys, networks, jobs, events, webhooks, ips and volumes: read |
| operator | read-only, plus servers:write, ips:write and volumes:write |
| admin | everything, including webhooks:write, ssh-keys:write and billing:read |
| Scope | Allows |
|---|---|
| servers:read | List and read servers, sizes, images and regions |
| servers:write | Create, delete, start, stop, reboot and label servers |
| ssh-keys:read / write | List, add and remove SSH keys |
| networks:read | List networks |
| jobs:read | Read job status |
| events:read | Read the event feed |
| webhooks:read / write | List, create and delete webhook endpoints |
| ips:read / write | List, allocate, attach, move and release public IPv4 addresses |
| volumes:read / write | Volumes and snapshots (when enabled) |
| billing:read | Costs and invoices |
Create, delete, start, stop and reboot answer 202 Accepted with a job. Poll GET /v1/jobs/{id} until status is succeeded or failed (error says why), or subscribe to webhooks and skip polling.
curl -H "Authorization: Bearer $KEY" https://api.cloud.clk.no/v1/jobs/0b9c…
{ "id": "0b9c…", "status": "succeeded", "createdAt": "2026-10-08T12:00:00Z" }
curl -H "Authorization: Bearer $KEY" "https://api.cloud.clk.no/v1/servers?label=env:prod"Servers can be filtered by label (?label=env:prod, repeat for AND), and costs grouped by label (/v1/usage?group_by=label:env), so labels are the way to split cost per team or project.
Register an https endpoint with POST /v1/webhooks (or in the console) and choose the event types. The response includes a signing secret, shown once. Endpoints must resolve to public addresses; private, loopback and link-local destinations are refused. Up to 10 endpoints per organization.
{
"id": "5d0e…",
"type": "server.created",
"created_at": "2026-10-08T12:01:10Z",
"organization_id": "a1b2…",
"data": { … }
}Each delivery is a POST with Content-Type: application/json, a 10 second timeout, and the headers CLK-Event-Id, CLK-Event-Type and CLK-Signature. Any 2xx counts as delivered. Failures are retried after 1, 5, 30 minutes and 2, 6 and 12 hours. Delivery is at least once: use the event id to ignore duplicates. A delivery log with replay is in the console.
CLK-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "t.body" with your secret. Compute it over the raw request bytes and reject old timestamps. While a secret is being rotated, the header carries a second v1 signed with the previous secret for 24 hours.
import { createHmac, timingSafeEqual } from 'node:crypto'
// rawBody must be the exact bytes received, before any JSON parsing.
export function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
const t = Number(parts.t)
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
// During a secret rotation the header holds two v1= values; accept either.
return header.split(',').filter((p) => p.startsWith('v1=')).some((p) => {
const got = p.slice(3)
return got.length === expected.length && timingSafeEqual(Buffer.from(got), Buffer.from(expected))
})
}import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
items = [p.split("=", 1) for p in header.split(",")]
t = int(next(v for k, v in items if k == "t"))
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(v, expected) for k, v in items if k == "v1")| Area | Events |
|---|---|
| Servers | server.created · server.creation_failed · server.started · server.stopped · server.rebooted · server.deleted · server.action_failed |
| Account | account.approved · account.rejected · account.card_changed · account.closure_scheduled · account.closure_cancelled · account.suspended · account.resumed · account.blocked · account.unblocked |
| Networking | ip.allocated · ip.released · firewall.rule_created · firewall.rule_deleted · port_forward.created · port_forward.deleted |
| Storage | volume.created · volume.attached · volume.detached · volume.deleted · snapshot.created · snapshot.deleted |
| Billing | invoice.created · invoice.paid · invoice.payment_failed · budget.threshold · credit.granted · coupon.redeemed · referral.signed_up · referral.rewarded |
| Platform | announcement.created · announcement.updated · announcement.resolved |
Prefer polling? GET /v1/events?after=<last event id> returns the same events. Events and delivery logs are kept for 30 days.
Three ready-made clients, all built on the same API. Source and downloads are in the clk-cli repository.
export CLK_API_KEY=clk_live_…
clk servers create --name web-1 --size "CLK Dedicated Extra Small" --label env=prod --ttl 2h --wait
clk servers list --label env=prod
clk servers delete <id> --yes --wait
clk events --followResources clk_server, clk_ssh_key and clk_webhook. Only labels change in place; other changes replace the server.
resource "clk_ssh_key" "me" {
name = "laptop"
public_key = file("~/.ssh/id_ed25519.pub")
}
resource "clk_server" "web" {
name = "web-1"
size = "CLK Dedicated Extra Small"
ssh_key = clk_ssh_key.me.name
labels = { env = "prod" }
}clk-mcp speaks the Model Context Protocol, so Claude and other agents can list sizes, create servers, read events and costs. Servers an agent creates are labelled created-by=mcp, it can only delete those, and CLK_MCP_READONLY=1 removes every tool that changes something.
claude mcp add clk -e CLK_API_KEY=clk_live_… -e CLK_MCP_DEFAULT_TTL=2h -- clk-mcpThe API is built to be driven by agents as well as people. Give each agent its own key with the narrowest role (read-only or operator), a short expiry and caps, pass ttl when it creates a server so a forgotten server deletes itself, label everything it creates (for example agent=release-bot), and have it delete what it creates. Costs can then be split per agent with /v1/usage?group_by=label:agent, and the audit log shows what each key did.
For tools that read documentation, /llms.txt is an index and /llms-full.txt is the whole API in one plain-text file. The machine-readable OpenAPI spec is linked below.
Every error has the same shape. Quote the request_id (also in the X-Request-Id header on every response) when you write to support.
HTTP/1.1 403 Forbidden
X-Request-Id: 6f1c…
{ "error": { "code": "forbidden", "message": "this key lacks the scope servers:write", "request_id": "6f1c…" } }| Status | code | Meaning |
|---|---|---|
| 400 | invalid_request | Bad or missing field; the message says which |
| 401 | unauthorized | Key missing, invalid, expired or revoked |
| 403 | forbidden | The key lacks the scope this endpoint needs |
| 422 | key_limit_exceeded | The key's own server or monthly cost cap would be exceeded |
| 429 | rate_limited | Too many requests; wait Retry-After seconds |
Rate limit: 120 requests per minute per key. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds). Other limits: user data up to 16 KB, up to 20 labels per server.
Error codes are stable; messages are for humans and may change.
/v1 only changes in backwards compatible ways: new endpoints, optional request fields, response fields, event types and enum values can appear at any time, so ignore what you don't recognise. A breaking change ships as /v2, and /v1 keeps working for at least 12 months after that is announced. Deprecations are announced at least 6 months ahead, and the endpoint then answers with Deprecation and Sunset headers.
Every endpoint is listed in the API reference. The machine-readable OpenAPI spec is at https://api.cloud.clk.no/swagger/v3/api-docs/v1.