# clx management API

Everything the clx.cx app does goes through these endpoints: connect a Cloudflare account, install the clx-edge worker into it, add sites and take their counter snippet, make short links with rules and QR codes, read reports. An agent setting clx up for a person starts at [clx for AI agents](https://clx.cx/agents).

The same contract, machine-readable: [openapi.yaml](https://clx.cx/openapi.yaml) · [openapi.json](https://clx.cx/openapi.json) (OpenAPI 3.1.0). This page as markdown: [/api.md](https://clx.cx/api.md), or `Accept: text/markdown`.

## Conventions

-   **Base URL** `https://clx.cx`, JSON in and out.
-   **Authentication** `Authorization: Bearer clx_…` — an API key: one on the free plan, up to 5 on the `api` plan (higher limits, switched on by clx on request). A person issues them in the app ([Keys](https://clx.cx/app#/keys)) with the scopes the key needs: `accounts` (connect, renew, disconnect, install), `sites`, `links`, `reports`. A key may also be limited to some Cloudflare accounts and client IPs. Another user's object answers `404`; a scope the key lacks, `403 scope_required`.
-   **Idempotency** Every `POST`, `PUT`, `PATCH` and `DELETE` takes an `Idempotency-Key` header, required with an API key. A repeat with the same key and body replays the first answer for 24 hours and does nothing again; the same key with another body is `409 idempotency_conflict`. Use a new key per intended action, the same key for its retries.
-   **Long operations** Connecting an account and installing the worker answer `202` with the object as it is; poll its `GET` every few seconds until the state settles. `409 operation_in_progress` means another operation on that Cloudflare account is running — retry shortly.
-   **Rate limits** 120 requests a minute per key; over it, `429` with `Retry-After` in seconds. Reports read the account's own database for breakdowns and for a link's live days — 30 such reads a minute per clx user, each cached 5 minutes; over that the report still answers `200`, without them, and `unavailable` says `rate_limited`: wait a minute and ask again.
-   **Errors** `{"error": {"code", "message", "details"?}}`. The codes are stable — branch on `code`, show `message` to the person. Stable codes: unauthorized, `invalid_request`, `not_found`, `payload_too_large`, `limit_reached`, `service_full`, `idempotency_conflict`, `operation_in_progress`, `scope_required`, `session_required`, `key_already_issued`, `account_taken`, `already_connected`, `account_not_ready`, `invalid_bootstrap`, `bootstrap_permission_error`, `permission_catalog`, `orphan_not_deleted`, `token_rights_mismatch`, `token_check_failed`, `renew_lost`, `rate_limited`, `cloudflare_unavailable`, `not_configured`, internal. In an account's or operation's `error` (the install, [§4](https://github.com/investblog/clx/blob/main/docs/spec.md)): `name_taken`, `resource_drift`, `cron_limit`, `permission_error`, revoked, `self_check_timeout`, `credentials_missing`, `credentials_unreadable` (clx cannot open the stored token), `cloudflare_error`. Sites: `zone_not_found`, `site_exists`, `route_conflict`, `site_not_active`, `route_not_ours` (a route clx made was changed outside clx and left in place). Sign-up: `email_unconfirmed` (POST /v1/accounts before the address is confirmed), `invalid_password` (DELETE /v1/me). Links: `link_host_required` (set the account's link host first), `link_exists` (the code is taken in that account).

## You and your API keys

### `GET /v1/me`

The caller — user, plan, limits, current use

Sign-up, e-mail confirmation and password reset are not part of /v1: they live under /auth with the page session ([docs/spec.md](https://github.com/investblog/clx/blob/main/docs/spec.md) [§9](https://github.com/investblog/clx/blob/main/docs/spec.md)). Until `email_confirmed`, POST /v1/accounts answers `email_unconfirmed`.

Answers:

-   `200` OK — `user`: object, `plan`: one of `free`, `api`, `limits`: object, `use`: object, `via`: one of `session`, `key`

### `DELETE /v1/me`

Delete the account (page session only, with the password)

Every connected Cloudflare account is disconnected first (clx-edge, its database and routes removed where the token still can — what could not be is listed in `left`, the working tokens to revoke in `revoke_tokens`); then the totals go at once, and the user with the keys, sites and links.

Body (JSON):

-   `password` (required): string

Takes an `Idempotency-Key` header — required with an API key.

Answers:

-   `200` Deleted — `ok`: boolean, `left`: array of string, `revoke_tokens`: array of string

### `GET /v1/keys`

API keys (page session only)

Answers:

-   `200` OK — `keys`: array of [Key](#object-key)

### `POST /v1/keys`

Issue an API key (page session only; 1 on the free plan, 5 on api — `limit_reached` over it); the key is in this answer only

Body (JSON):

-   `scopes` (required): array of one of `accounts`, `sites`, `links`, `reports`
-   `allow_accounts`: array of string, `^[0-9a-f]{32}$`
-   `allow_ips`: array of string

Takes an `Idempotency-Key` header — required with an API key.

Answers:

-   `201` Issued — `key` is shown once
-   `409` `key_already_issued` — a replay of the same Idempotency-Key

### `DELETE /v1/keys/{id}`

Revoke an API key (page session only)

Parameters:

-   `id` (path, required): string

Takes an `Idempotency-Key` header — required with an API key.

Answers:

-   `200` Revoked

## Cloudflare accounts

### `GET /v1/accounts`

Connected Cloudflare accounts (scope accounts or sites)

Answers:

-   `200` OK — `accounts`: array of [Account](#object-account)

### `POST /v1/accounts`

Connect a Cloudflare account with a bootstrap token (scope accounts)

The bootstrap token (Account Settings — Read + Account API Tokens — Edit, which the API names Write; one day) is used within this request and deleted; it is never stored. The account then goes on to the install of clx-edge ([§4](https://github.com/investblog/clx/blob/main/docs/spec.md)) — poll GET for its state: `installing`, then `ready` once the script, its database and cron are in place (seconds; the worker's own confirmation may follow minutes later, or never — then `error.warnings` says `not_confirmed`), or `connected` with an `error`.

Body (JSON):

-   `cf_account_id` (required): string, `^[0-9a-f]{32}$`
-   `bootstrap_token` (required): string

Takes an `Idempotency-Key` header — required with an API key.

Answers:

-   `202` Connected — `account`: [Account](#object-account)

### `GET /v1/accounts/{id}`

One connected account (scope accounts or sites), with its latest operation and link host

Parameters:

-   `id` (path, required): string

Answers:

-   `200` OK — `account`: [Account](#object-account)

### `DELETE /v1/accounts/{id}`

Disconnect (scope accounts) — delete clx-edge and its database, then the record

The answer names the working token to revoke in Cloudflare (it cannot delete itself), and in `left` whatever clx could not delete — the token is revoked, a right is missing, or the worker was changed outside clx — for the user to remove by hand.

Parameters:

-   `id` (path, required): string

Takes an `Idempotency-Key` header — required with an API key.

Answers:

-   `200` Disconnected — `ok`: boolean, `revoke_token`: string | null, `left`: array of string

### `GET /v1/accounts/{id}/zones`

The account's zones (scope accounts or sites) — to pick a site's or link host's zone from

Read live from Cloudflare with the working token, sorted by name; at most 1,000, `truncated` past that. A host is still checked against its zone when it is added.

Parameters:

-   `id` (path, required): string

Answers:

-   `200` OK — `zones`: array of object, `truncated`: boolean

### `POST /v1/accounts/{id}/install`

Install clx-edge again (scope accounts) — after a failed install, or to reinstall

The recorded database is kept; the worker gets a new key (the previous one is accepted for a day). A clx-edge worker or database clx did not create is never touched (`name_taken`), nor a worker changed outside clx (`resource_drift`). Poll GET for the state.

Parameters:

-   `id` (path, required): string

Takes an `Idempotency-Key` header — required with an API key.

Answers:

-   `202` Started — `account`: [Account](#object-account)

### `PUT /v1/accounts/{id}/link-host`

Set the account's link host (scope sites) — a host in one of its zones, routed whole to clx-edge

The account must be `ready`. The host needs a proxied DNS record — clx does not create it ([§13](https://github.com/investblog/clx/blob/main/docs/spec.md) item 2): e.g. `AAAA <host> 100::`, proxied. Setting another host replaces this one: its route is deleted and the links move to the new host with their codes.

Parameters:

-   `id` (path, required): string

Body (JSON):

-   `host` (required): string — go.example.com — a host of a zone in that account

Takes an `Idempotency-Key` header — required with an API key.

Answers:

-   `202` Set — `link_host`: [LinkHost](#object-linkhost)

### `POST /v1/accounts/{id}/token`

Renew the working token with a new bootstrap token (scope accounts)

Parameters:

-   `id` (path, required): string

Body (JSON):

-   `bootstrap_token` (required): string

Takes an `Idempotency-Key` header — required with an API key.

Answers:

-   `200` Renewed

## Sites

### `GET /v1/sites`

Sites (scope sites); `account_id` narrows to one account

Parameters:

-   `account_id` (query): string

Answers:

-   `200` OK — `sites`: array of [Site](#object-site)

### `POST /v1/sites`

Add a site (scope sites) — the zone is found in the account, the route made, the config synced

The account must be `ready`. The answer carries the snippet at once; `config` turns `synced` once the worker has the site (seconds), and the counter answers from then on.

Body (JSON):

-   `account_id` (required): string
-   `host` (required): string — example.com or a subdomain of a zone in that account
-   `excluded_paths`: array of string, `^/`, up to 50

Takes an `Idempotency-Key` header — required with an API key.

Answers:

-   `202` Added — `site`: [Site](#object-site)

### `GET /v1/sites/{id}`

One site (scope sites)

Parameters:

-   `id` (path, required): string

Answers:

-   `200` OK — `site`: [Site](#object-site)

### `PATCH /v1/sites/{id}`

Change the excluded paths (scope sites)

Parameters:

-   `id` (path, required): string

Body (JSON):

-   `excluded_paths`: array of string, `^/`, up to 50

Takes an `Idempotency-Key` header — required with an API key.

Answers:

-   `200` Changed — `site`: [Site](#object-site)

### `DELETE /v1/sites/{id}`

Remove the site's routes (scope sites); the site stays as `deleted`, its totals with it

Parameters:

-   `id` (path, required): string

Takes an `Idempotency-Key` header — required with an API key.

Answers:

-   `200` Deleted — `site`: [Site](#object-site)

### `POST /v1/sites/{id}/rotate`

A new path, names and snippet (scope sites); the old path is counted for 30 more days

Parameters:

-   `id` (path, required): string

Takes an `Idempotency-Key` header — required with an API key.

Answers:

-   `200` Rotated — `site`: [Site](#object-site)

## Links

### `GET /v1/links`

Links (scope links); `account_id` narrows to one account

Parameters:

-   `account_id` (query): string

Answers:

-   `200` OK — `links`: array of [Link](#object-link)

### `POST /v1/links`

Add a short link (scope links) on the account's link host

A click is a `302` to the first matching rule's URL, else `url`; it is counted as a view of the link, with source `qr` when the address carries `?q` (the QR code's) — or as a bot when the client is one (curl, a script, a crawler: it is redirected all the same). `config` turns `synced` once the worker has the link (seconds).

Body (JSON):

-   `account_id` (required): string
-   `code`: string, `^[A-Za-z0-9_-]{3,32}$` — a random 6 characters when not given
-   `url` (required): string (uri) — https:// only, up to 2048 characters, not on the link host
-   `rules`: array of [Rule](#object-rule), up to 10

Takes an `Idempotency-Key` header — required with an API key.

Answers:

-   `201` Added — `link`: [Link](#object-link)

### `GET /v1/links/{id}`

One link (scope links)

Parameters:

-   `id` (path, required): string

Answers:

-   `200` OK — `link`: [Link](#object-link)

### `PATCH /v1/links/{id}`

Change the URL or the rules (scope links); the code stays

Parameters:

-   `id` (path, required): string

Body (JSON):

-   `url`: string (uri)
-   `rules`: array of [Rule](#object-rule), up to 10

Takes an `Idempotency-Key` header — required with an API key.

Answers:

-   `200` Changed — `link`: [Link](#object-link)

### `DELETE /v1/links/{id}`

Remove the link (scope links); its code is freed, the link stays as `deleted` with its totals

Parameters:

-   `id` (path, required): string

Takes an `Idempotency-Key` header — required with an API key.

Answers:

-   `200` Deleted — `link`: [Link](#object-link)

### `GET /v1/links/{id}/qr.svg`

The QR code of the link (scope links) — an SVG of `https://<link host>/<code>?q`

The `?q` makes a scan count with source `qr`; the worker does not pass it on. One `<rect>` and one `<path>`, integer coordinates, a quiet zone of 4 modules, error correction M. Without `size` the SVG has no width and height and scales to its container. It follows the link host: a replaced host makes earlier printed codes stop working.

Parameters:

-   `id` (path, required): string
-   `size` (query): integer, 64–9999 — pixels

Answers:

-   `200` The SVG — string

## Reports

### `GET /v1/sites/{id}/report`

Totals and series of the site (scope reports); with breakdowns=1 also the top pages, sources, countries, devices, browsers, OS and bots

Parameters:

-   `id` (path, required): string
-   `period` (query): one of `today`, `7d`, `30d`
-   `breakdowns` (query): one of `1` — read the breakdowns from the account's database (cached 5 minutes)

Answers:

-   `200` OK — `report`: [Report](#object-report)

### `GET /v1/links/{id}/report`

Clicks of the link (scope reports); with breakdowns=1 also sources, countries, devices, browsers, OS and bots (`pages` lists no pages for a link: at most one empty key)

Closed days come from clx.cx; today (and any day the worker has not sent yet) is read live from the account's own database in one read with the breakdowns — `as_of` is the moment of that read (cached up to 5 minutes, so it can be that much older than the answer). When that database cannot be read, the totals hold clx.cx's days only, the series of `today` is empty and `unavailable` says why. `incomplete` is always false: a link's days are never refused.

Parameters:

-   `id` (path, required): string
-   `period` (query): one of `today`, `7d`, `30d`
-   `breakdowns` (query): one of `1`

Answers:

-   `200` OK — `report`: [Report](#object-report)

## Objects

### Key

-   `id`: string
-   `prefix`: string
-   `scopes`: array of one of `accounts`, `sites`, `links`, `reports`
-   `allow_accounts`: array of string | null
-   `allow_ips`: array of string | null
-   `created_at`: string (date-time)
-   `last_used`: string (date) | null

### Account

-   `id`: string
-   `cf_account_id`: string
-   `name`: string
-   `state`: one of `pending`, `bootstrap_lost`, `connected`, `installing`, `ready`, `permission_error`, `revoked`, `resource_drift`, `no_connection` — pending — the connect is running, or stopped on a passing failure (5xx other than `token_rights_mismatch`, `token_check_failed`, `permission_catalog`, which end it) and waits for the same request again (same Idempotency-Key); `bootstrap_lost` — a connect left pending for an hour, or one that died before clx stored its working token: revoke the token its error names and connect again; connected — token ready, clx-edge not installed; installing — the install runs; ready — installed and serving, go on (the worker confirms itself on its first cron: meanwhile `operation` shows the install at step selfcheck, for up to 15 minutes, and if no confirmation comes error.warnings says `not_confirmed` — nothing to wait for either way); `no_connection` — installed, but no push from the worker for 26 hours (sites can still be added; its next push makes it ready again)
-   `token`: object | null
    -   `name`: string — clx-<operation id>
    -   `expires_at`: string (date-time)
-   `error`: object | null — code and details, or warnings such as `bootstrap_not_deleted`
-   `edge`: object | null — The installed clx-edge, from its upload on (the worker's own confirmation is not needed)
    -   `bundle`: string — sha256 of the bundle
    -   `up_to_date`: boolean
    -   `schema`: integer
    -   `installed_at`: string (date-time)
-   `push`: object | null — The worker's own report from its last push of totals (hourly)
    -   `at`: string (date-time)
    -   `bundle`: string — sha256 of the bundle the worker runs
    -   `schema`: integer | null
    -   `queue`: integer — queue parts and final days waiting to be sent
    -   `error`: string | null — the worker's last error
    -   `dropped`: object — items dropped unsent, by reason: budget, invalid, `unknown_target`, `too_old`, busy, expired
-   `advice`: object | null — When to move this Cloudflare account to Workers Paid, from its measured use over the last 7 complete UTC days; recomputed daily, null until first computed
    -   `level`: one of `ok`, `watch`, `upgrade_soon`, `over` — the worst metric: over — a limit was reached (or the database is at backpressure, 400 MB); `upgrade_soon` — busiest day ≥ 80% or the limit within 7 days; watch — ≥ 60% or within 30 days
    -   `metric`: one of `requests`, `writes`, `reads`, `size`, or null
    -   `metrics`: object — requests, writes, reads — per day for the whole account, against 100,000 / 100,000 / 5,000,000; size — clx-edge now, against 500 MB
    -   `unavailable`: array of one of `analytics`, `size` — what could not be read — not guessed
    -   `suggest`: one of `shorter_hourly_retention` — size alone is the problem
    -   `at`: string (date-time)
-   `operation`: object | null — The latest operation on the account (GET /v1/accounts/{id} only)
    -   `kind`: one of `connect`, `renew`, `install`, `update`, `disconnect`
    -   `state`: one of `running`, `done`, `failed`
    -   `step`: string
    -   `error`: object | null
    -   `updated_at`: string (date-time)
-   `link_host`: [LinkHost](#object-linkhost) | null — The account's link host (GET /v1/accounts/{id} only)
-   `created_at`: string (date-time)

### LinkHost

-   `host`: string
-   `state`: one of `route_pending`, `active`, `route_conflict` — `route_pending` — the route `<host>/*` is still to be made (the cron retries); `route_conflict` — another worker holds it, see error
-   `config`: one of `pending`, `synced`
-   `error`: object | null

### Rule

matches when the visitor's country is in countries (if given) and their device in devices (if given)

-   `countries`: array of string, `^[A-Z]{2}$`
-   `devices`: array of one of `mobile`, `desktop`
-   `url` (required): string (uri) — https:// only, up to 2048 characters, not on the link host

### Link

-   `id`: string
-   `account_id`: string
-   `code`: string
-   `short_url`: string | null — https://<link host>/<code>
-   `url`: string — where it leads when no rule matches
-   `rules`: array of [Rule](#object-rule), up to 10 — the first match wins
-   `state`: one of `active`, `deleted`
-   `config`: one of `pending`, `synced` — whether the worker has this version of the link yet
-   `clicks_7d`: integer — clicks of the last 7 closed UTC days (GET /v1/links only, and only with the reports scope)
-   `created_at`: string (date-time)

### Site

-   `id`: string
-   `account_id`: string
-   `host`: string
-   `state`: one of `route_pending`, `active`, `route_conflict`, `deleted` — `route_pending` — the route is still to be made (the cron retries); `route_conflict` — another worker holds the pattern, see error
-   `path`: string — the per-site path the counter answers under, e.g. /lumora or /kavi/tesomu
-   `snippet`: object | null — Embed one of the two at build time; stable until rotate
    -   `inline`: string — <script>…</script>, under 600 bytes
    -   `script_tag`: string — <script src="/<path>/<s>.js"> with defer (the order of the attributes varies by site)
-   `excluded_paths`: array of string
-   `config`: one of `pending`, `synced` — whether the worker has this version of the site yet
-   `retiring`: array of object — old paths still counted after a rotation
-   `error`: object | null
-   `created_at`: string (date-time)

### Top

the top 10 by count; "(other)" holds what went over the detail caps

array of object, up to 10 — each with `key`, `n`

### Report

-   `period`: one of `today`, `7d`, `30d`
-   `from`: string (date) — first UTC day
-   `to`: string (date) — last UTC day — today
-   `as_of`: string (date-time) | null — today is counted up to this moment. A site: the end of the latest closed hour the worker sent (hours arrive up to an hour late), null before its first push. A link: the moment of the live read of the account's database
-   `totals`: object
    -   `views`: integer
    -   `bots`: integer
    -   `visitors`: integer — the sum of daily unique visitors
-   `series`: array of object — today — one point per hour up to `as_of`; 7d/30d — one per day (with visitors)
-   `incomplete`: boolean — today's hours were refused over the account's write budget; daily totals stay exact
-   `breakdowns`: object | null — only with breakdowns=1; read from the account's own database, null when unavailable
    -   `pages`: [Top](#object-top)
    -   `sources`: [Top](#object-top)
    -   `countries`: [Top](#object-top)
    -   `devices`: [Top](#object-top)
    -   `browsers`: [Top](#object-top)
    -   `os`: [Top](#object-top)
    -   `bots`: [Top](#object-top)
-   `unavailable`: one of `not_installed`, `cloudflare`, `rate_limited` — why breakdowns (and a link's today) are not there: the worker is not installed or the token is gone; Cloudflare failed or took over 5 s; over 30 reads of the account databases a minute for this clx user (each cached 5 minutes)
