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.
The same contract, machine-readable: openapi.yaml · openapi.json (OpenAPI 3.1.0). This page as markdown: /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 theapiplan (higher limits, switched on by clx on request). A person issues them in the 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 answers404; a scope the key lacks,403 scope_required. - Idempotency Every
POST,PUT,PATCHandDELETEtakes anIdempotency-Keyheader, 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 is409 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
202with the object as it is; poll itsGETevery few seconds until the state settles.409 operation_in_progressmeans another operation on that Cloudflare account is running — retry shortly. - Rate limits 120 requests a minute per key; over it,
429withRetry-Afterin 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 answers200, without them, andunavailablesaysrate_limited: wait a minute and ask again. - Errors
{"error": {"code", "message", "details"?}}. The codes are stable — branch oncode, showmessageto 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'serror(the install, §4):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 §9). Until email_confirmed, POST /v1/accounts answers email_unconfirmed.
Answers:
200OK —user: object,plan: one offree,api,limits: object,use: object,via: one ofsession,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:
200Deleted —ok: boolean,left: array of string,revoke_tokens: array of string
GET /v1/keys
API keys (page session only)
Answers:
200OK —keys: array of 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 ofaccounts,sites,links,reportsallow_accounts: array of string,^[0-9a-f]{32}$allow_ips: array of string
Takes an Idempotency-Key header — required with an API key.
Answers:
201Issued —keyis shown once409key_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:
200Revoked
Cloudflare accounts
GET /v1/accounts
Connected Cloudflare accounts (scope accounts or sites)
Answers:
200OK —accounts: array of 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) — 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:
202Connected —account: 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:
200OK —account: 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:
200Disconnected —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:
200OK —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:
202Started —account: 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 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:
202Set —link_host: 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:
200Renewed
Sites
GET /v1/sites
Sites (scope sites); account_id narrows to one account
Parameters:
account_id(query): string
Answers:
200OK —sites: array of 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): stringhost(required): string — example.com or a subdomain of a zone in that accountexcluded_paths: array of string,^/, up to 50
Takes an Idempotency-Key header — required with an API key.
Answers:
202Added —site: Site
GET /v1/sites/{id}
One site (scope sites)
Parameters:
id(path, required): string
Answers:
200OK —site: 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:
200Changed —site: 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:
200Deleted —site: 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:
200Rotated —site: Site
Links
GET /v1/links
Links (scope links); account_id narrows to one account
Parameters:
account_id(query): string
Answers:
200OK —links: array of 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): stringcode: string,^[A-Za-z0-9_-]{3,32}$— a random 6 characters when not givenurl(required): string (uri) — https:// only, up to 2048 characters, not on the link hostrules: array of Rule, up to 10
Takes an Idempotency-Key header — required with an API key.
Answers:
201Added —link: Link
GET /v1/links/{id}
One link (scope links)
Parameters:
id(path, required): string
Answers:
200OK —link: 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, up to 10
Takes an Idempotency-Key header — required with an API key.
Answers:
200Changed —link: 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:
200Deleted —link: 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): stringsize(query): integer, 64–9999 — pixels
Answers:
200The 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): stringperiod(query): one oftoday,7d,30dbreakdowns(query): one of1— read the breakdowns from the account's database (cached 5 minutes)
Answers:
200OK —report: 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): stringperiod(query): one oftoday,7d,30dbreakdowns(query): one of1
Answers:
200OK —report: Report
Objects
Key
id: stringprefix: stringscopes: array of one ofaccounts,sites,links,reportsallow_accounts: array of string | nullallow_ips: array of string | nullcreated_at: string (date-time)last_used: string (date) | null
Account
id: stringcf_account_id: stringname: stringstate: one ofpending,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 thantoken_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: meanwhileoperationshows the install at step selfcheck, for up to 15 minutes, and if no confirmation comes error.warnings saysnot_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 | nullname: string — clx-<operation id>expires_at: string (date-time)
error: object | null — code and details, or warnings such asbootstrap_not_deletededge: object | null — The installed clx-edge, from its upload on (the worker's own confirmation is not needed)bundle: string — sha256 of the bundleup_to_date: booleanschema: integerinstalled_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 runsschema: integer | nullqueue: integer — queue parts and final days waiting to be senterror: string | null — the worker's last errordropped: 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 computedlevel: one ofok,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 daysmetric: one ofrequests,writes,reads,size, or nullmetrics: object — requests, writes, reads — per day for the whole account, against 100,000 / 100,000 / 5,000,000; size — clx-edge now, against 500 MBunavailable: array of one ofanalytics,size— what could not be read — not guessedsuggest: one ofshorter_hourly_retention— size alone is the problemat: string (date-time)
operation: object | null — The latest operation on the account (GET /v1/accounts/{id} only)kind: one ofconnect,renew,install,update,disconnectstate: one ofrunning,done,failedstep: stringerror: object | nullupdated_at: string (date-time)
link_host: LinkHost | null — The account's link host (GET /v1/accounts/{id} only)created_at: string (date-time)
LinkHost
host: stringstate: one ofroute_pending,active,route_conflict—route_pending— the route<host>/*is still to be made (the cron retries);route_conflict— another worker holds it, see errorconfig: one ofpending,syncederror: 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 ofmobile,desktopurl(required): string (uri) — https:// only, up to 2048 characters, not on the link host
Link
id: stringaccount_id: stringcode: stringshort_url: string | null — https://<link host>/<code>url: string — where it leads when no rule matchesrules: array of Rule, up to 10 — the first match winsstate: one ofactive,deletedconfig: one ofpending,synced— whether the worker has this version of the link yetclicks_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: stringaccount_id: stringhost: stringstate: one ofroute_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 errorpath: string — the per-site path the counter answers under, e.g. /lumora or /kavi/tesomusnippet: object | null — Embed one of the two at build time; stable until rotateinline: string — <script>…</script>, under 600 bytesscript_tag: string — <script src="/<path>/<s>.js"> with defer (the order of the attributes varies by site)
excluded_paths: array of stringconfig: one ofpending,synced— whether the worker has this version of the site yetretiring: array of object — old paths still counted after a rotationerror: object | nullcreated_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 oftoday,7d,30dfrom: string (date) — first UTC dayto: string (date) — last UTC day — todayas_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 databasetotals: objectviews: integerbots: integervisitors: integer — the sum of daily unique visitors
series: array of object — today — one point per hour up toas_of; 7d/30d — one per day (with visitors)incomplete: boolean — today's hours were refused over the account's write budget; daily totals stay exactbreakdowns: object | null — only with breakdowns=1; read from the account's own database, null when unavailableunavailable: one ofnot_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)