Developers
Public API (Cliedesk Pro)
This guide covers a feature of Cliedesk Pro. The free plugin is a complete help desk; see what Pro adds.
The Public API is the documented, versioned API for other systems: /wp-json/cliedesk-api/v1 (plain permalinks:
?rest_route=/cliedesk-api/v1/…). It exists only while Cliedesk Pro runs with a valid licence. Without Pro, with Pro
inactive or with Pro unlicensed, WordPress answers its own 404 rest_no_route: treat that answer on GET / as “the
Public API is not available on this site”. Free’s internal API cliedesk/v1 never accepts API keys.
Keys and authentication
- Keys look like
cdk_live_<12-char id>_<32-char secret>; test keys start withcdk_test_. Only a SHA-256 hash of the key is stored. The key is shown once (Settings → Developer → API keys, orwp cliedesk api key create). - Send it as
Authorization: Bearer cdk_live_…. Hosts that strip that header: useX-Cliedesk-Key: cdk_live_…. Never put a key in the URL: such a request gets400 CREDENTIALS_IN_URLand the key is flagged. - Cookies, Application Passwords and Basic auth never authenticate the Public API.
- Each key has an owner. Effective permission = the owner’s capabilities ∩ the key’s scopes ∩ the key’s product
restriction ∩ Policy. Records outside the actor’s scope answer
404. There is no scope for settings, roles, people, API keys, licence, vault, retention, permanent delete or approval decisions. - A person may create a key for someone else only when that person has no Cliedesk permission the creator lacks,
and never for an Owner (only the Owner creates keys that act as them):
422 API_KEY_OWNER_TOO_STRONG.wp cliedesk api key createis not limited. - The product restriction is deny by default (
CliedeskPro\Modules\Api\ProductGate). Every record reference in the input is checked: the pathid,conversation_id(s),source_ids(merge), conversationnumber,contact_id(s),source_id,entity+entity_id(tags),product_id(s)(create, move). A record outside the products is404. Search and reports are limited to the key’s products. A restricted key gets403 API_PRODUCT_RESTRICTEDfor what the gate cannot limit: bulk jobs, organizations (also their sync feed), webhook management (use REST Hooks, which carry the product filter), the contact timeline, reports without a product, and any other record reference it does not know (for example an add-on Action throughPOST /actions/{name}). - API keys, OAuth tokens, AI and rule actors cannot send notes or replies with macro steps (
macro.steps, andcc/thenon a reply):422 MACRO_STEPS_NEED_PERSON. Send the message, then make the changes with their own requests (or themacro_runMCP tool). - Keys expire after 365 days by default (setting
pro_api.default_expiry_days;0= never, needs a confirmed warning), can be revoked, can carry an IP allowlist (IPv4/IPv6 or CIDR, at most 50), and are flagged when unused for 90 days (pro_api.unused_days). Revoked, unknown and wrong keys get the same401 INVALID_CREDENTIALS; expired keys401 CREDENTIALS_EXPIRED; keys of owners who lost access401 OWNER_INACTIVE. - Test keys never change data. Actions that support dry runs run in a transaction that is rolled back and answer
the would-be result. For every other write the request is checked exactly as a live run (input, the owner’s
capability, record rules and the key’s products) and then not run:
datais{object: "dry_run", action, allowed: true, input}with the warningDRY_RUN_NOT_EXECUTED. No Action code runs, so nothing is written, sent, queued or announced. Both answers havemeta.dry_run: true.
Scopes: conversations:read|write|delete, messages:read|write, notes:write, contacts:read|write|delete,
organizations:read|write, workspace:read, tags:read|write, saved_replies:read|write, articles:read,
approvals:read, events:read, reports:read, webhooks:read|write. Add-ons add scopes with the
cliedesk_pro_api_scopes filter (scope ⇒ description); new scopes are never given to existing keys.
Plugging in another authenticator
API keys and OAuth access tokens (see OAuth 2.1 below) are CliedeskPro\Modules\Api\Authenticators; more are
added with the cliedesk_pro_api_authenticators filter. accepts( $token ) claims a token format,
authenticate( $token, $ip ) returns a Credential (via oauth, owner = the consenting user) or throws an
ApiError (401). Authenticators run only inside the permission callback of cliedesk-api routes.
Requests and answers
- Success:
{ "data": …, "meta": { "request_id": "…" } }. Lists:meta.limit,meta.has_more,meta.next_cursor,meta.total(withwith_total=true). - Error:
{ "error": { "code", "message", "request_id", "details" } }with UPPER_SNAKE codes anddetails.retryable. Branch oncode, never onmessage. - Every answer:
X-Request-Id(send your own, up to 64[A-Za-z0-9_-]),Cache-Control: no-store, private, and on authenticated answersX-RateLimit-Limit,-Remaining,-Reset,-Cost. - Bodies are JSON (
415 UNSUPPORTED_MEDIA_TYPEotherwise), at most 1 MiB. Unknown fields are refused by the Action schema (400 VALIDATION_FAILED). Idempotency-Keyon POST/PATCH/DELETE replays the stored answer (Idempotent-Replayed: true); the same key with another body is409 IDEMPOTENCY_KEY_REUSED. Bulk endpoints require it (400 IDEMPOTENCY_KEY_REQUIRED). The store is Free’s idempotency table with the scopeapi:{key}.- Single records send
ETag: "v12"; sendIf-Match: "v12"on writes, a changed record answers412 PRECONDITION_FAILED.
Endpoints
Every endpoint runs one Action through ActionRegistry::run (actor type api, the key owner’s capabilities as the
ceiling). Free Actions are not flagged public_api, so Pro keeps the endpoint catalogue in
CliedeskPro\Modules\Api\Catalogue (path → Action, scope, cost). Path placeholders carry the Action’s input names;
the JSON body (or the query of a GET) is the rest of the Action input, as documented in the OpenAPI document.
Representation (§2): field names are snake_case in requests and answers; unknown request fields are
400 UNKNOWN_FIELD. Records show prefixed ULIDs in id and in every reference: conv_ conversation,
contact_, org_ organization, msg_ message (notes too), att_ attachment, prod_ product, brand_,
team_, sr_ saved reply, wf_ workflow, job_ bulk job, appr_ approval, whk_ webhook, dlv_
delivery. Objects with an id carry object. Types without a ULID keep integer ids: people (WordPress users, e.g.
assignee_id, owners), tags, fields, statuses, conversation types, queues, segments, views, articles and events.
Conversations also keep their number. A path id with the wrong prefix, or a numeric id, answers 404 NOT_FOUND.
Timestamps are RFC 3339 UTC with milliseconds. The Actions keep numeric ids; the mapping happens only at the
API boundary (CliedeskPro\Modules\Api\Representation).
-
Meta:
GET /,GET /me,GET /openapi.json(this site, filtered to the key’s scopes),GET /actions,POST /actions/{name}(any Action flaggedpublic_apiwith anapi_scope, e.g. add-on Actions). -
Conversations, messages, notes, contacts, organizations, workspace structure (read only), tags, saved replies, article search, approvals (read only), reports and webhook subscriptions: see
docs/api/llms.txt. -
Bulk jobs:
POST /conversations/bulk,POST /contacts/bulkanswer202with the job andLocation: /jobs/{uid};GET /jobs/{uid}(progress: total, processed, failed, skipped),/jobs/{uid}/report(partial failures) and/jobs/{uid}/download(result file). Same jobs as the bulk actions of the app. The job routes show a key or OAuth app only the jobs it started (others answer404). Each operation also needs the scope of its single endpoint:replyandmacromessages:write,notenotes:write,trashconversations:delete/contacts:delete, tagstags:write,org_addorganizations:write. -
Add-ons add endpoints with the
cliedesk_pro_api_endpointsfilter (method, path, action, scope, tag, summary). -
AI (
Api\AiEndpoints; the routes and the scopesai:readandai:read_tracesexist only while the Pro AI module runs, else404and the scopes cannot be given):GET /ai/profilesandGET /ai/playbooks(ai:read, metadata only: no goal text, data access, caps, tools, trigger text or steps; the owner needs Manage AI) andGET /ai/traces/{uid}(ai:read_traces, the redacted trace; the owner needscliedesk_view_ai_traces).ai:readalso lets a key receiveai.*webhook and stream events. The headless AI agent adds/ai/agent/*(ai-agent-channels.md). -
Surveys (surveys.md):
GET /survey-responses(filterskind,survey_id,contact_id,product_id,answered, cursorafter),GET /survey-responses/{id}(surveys:read),POST /survey-responses(surveys:write; another tool writes a CSAT 1–5, NPS 0–10 or CES 1–7 answer bycontact_id,emailorconversation_id),GET /surveys(surveys:read),POST /surveys(surveys:write). A key limited to products sees and writes only answers of those products. -
Pro CRM (crm-pro.md):
POST /contacts/{id}/events(events:write; stored ascustom.*),POST /contacts/import/previewandPOST /contacts/import(contacts:write; dry run or Free’s background job),GET /custom-record-types,GET|POST /custom-records,GET|PATCH|DELETE /custom-records/{id}(records:read,records:write). -
Audit (data-protection.md):
GET /audit-log(filtersfrom,to,actor_type,actor_id,action,object_type,object_id,result,product_id, cursorbefore),GET /audit-log/verifyandGET /read-access-log(scopeaudit:read; the key owner needscliedesk_view_audit_log). Nothing changes or deletes entries. -
Custom channel events:
POST /channels/{uid}/events(registered by the custom channel module) accept an API key or OAuth token withchannels:write(the owner needs Manage channels) instead of the channel signature; without a credential the signature is required. Test keys are refused (403 TEST_KEY_NOT_ALLOWED: events cannot be simulated), and a key limited to products needs the channel’s product (404otherwise). Other modules use the same check through thecliedesk_pro_api_authorizefilter (null= no credential,true= allowed, or aWP_Error; an optional 4th argument is the record written to). -
cliedesk_pro_api_credential_revoked(action,key:<id>oroauth:<grant id>) runs when a key or an OAuth grant is revoked; REST Hooks remove the credential’s subscriptions at once.
OAuth 2.1
For apps that act as a person (MCP clients such as Claude, ChatGPT or VS Code, and other apps). API keys stay the way for server-to-server work.
- Metadata:
GET /oauth/metadata(RFC 8414) andGET /oauth/protected-resource(RFC 9728), also at/.well-known/oauth-authorization-serverand/.well-known/oauth-protected-resourceon pretty-permalink sites. Every401carriesWWW-Authenticate: Bearer resource_metadata="…". - Grant: authorization code with PKCE (
code_challenge_method=S256only) and refresh tokens. No client-credentials, implicit or password grant.resource(RFC 8707) must be the API base URL or a URL below it (the MCP endpoint). GET /oauth/authorizechecks the client and the redirect URI (exact match; HTTPS, orhttp://localhost/127.0.0.1for apps on the computer) and sends the browser to/desk/oauth/consent. The signed-in person sees the app, the address it returns to and the scopes, can untick scopes, and allows or denies. The answer carriescode,stateandiss.POST /oauth/token(form or JSON):authorization_code(code, redirect_uri, client_id, code_verifier) orrefresh_token. Access tokenscdk_oat_…live 60 minutes (pro_api.oauth_access_minutes, 5–60), refresh tokenscdk_ort_…30 days (pro_api.oauth_refresh_days) and rotate on every use; a reused rotated refresh token or a reused code revokes the whole grant. Only SHA-256 hashes are stored. Codes live 60 seconds.POST /oauth/revoke(RFC 7009) andPOST /oauth/register(RFC 7591): dynamic registration is on by default (pro_api.oauth_registration; Settings → Developer → OAuth apps), at most 200 dynamic clients (pro_api.oauth_client_limit; clients an admin adds count separately, so registrations never block them), at most 20 registrations per address a day. Dynamic clients that never got a grant are removed after 24 hours, those without grants unused for 30 days too (pro_api.oauth_unused_client_days). Errors of/oauth/authorizefor a dynamic client are shown as an error page, never redirected (no open redirect). Code exchange and refresh rotation claim the row atomically: a parallel second use counts as reuse. Public clients usetoken_endpoint_auth_method=none; confidential clients get a secret once (client_secret_postorclient_secret_basic). At most 20 connected apps per person.- A token acts as the person: their capabilities ∩ the granted scopes ∩ Policy. Rate limits count per person across their apps. People disconnect apps in Settings → Team → Connected apps; admins block or remove apps.
- OAuth endpoints answer errors as
{error, error_description}(RFC 6749), not the API envelope. - CORS is off on the namespace. Only the OAuth endpoints (and routes added with
cliedesk_pro_api_cors_routes, such as MCP) answer CORS for origins inpro_api.oauth_allowed_origins(empty by default).
Lists, filters and the sync feed
limit(1–100, default 25) and an opaque, signedcursor. A cursor used with other filters or changed by hand is400 INVALID_CURSOR. The order is the stable keyset order of the Free list, with no result cap.filter[field][op]=valuewitheq,ne,in,nin(comma list) andexistsfrom each list’s allowlist, at most 10 clauses (400 INVALID_FILTER). Conversations:status_category,status,priority,channel,type,product_id,team_id,assignee_id(none= unassigned),contact_id,tag,language. Contacts:email,state,q. Organizations:name,parent_id.sortfrom the allowlist (400 INVALID_SORT),fields=a,b(top-level fields,400 UNKNOWN_FIELD),expand=contacton conversations (needscontacts:read, else403for the whole request).GET /sync/{object}(conversations,contacts,organizations; scopeevents:readplus the object’s read scope, e.g.conversations:read) returns every object changed after the cursor, oldest first, with id, version, change time anddeleted. The cursor comes back on every page, also the last, so store it and ask again later. Start from a time withfilter[updated_at][gte]=2026-10-01T00:00:00Z.- Semantic search comes with the Pro AI module.
Rate limits
Cost units per key per minute (default 600, setting pro_api.rate_limit_per_min, per key 60–6,000), writes per key
per minute (default 120, pro_api.write_limit_per_min), and a site ceiling (default 3,000,
pro_api.site_limit_per_min). Costs: single GET 1, list 2 (+1 per expand, +5 with totals), search 5, write 2,
reports 10, bulk 50. The 601st unit in a minute gets 429 RATE_LIMITED with Retry-After and
details.bucket (key, key_write, site, auth_failures). 20 failed authentications from one IP in 10 minutes
block that IP for the namespace. The counters are a sliding window in the persistent object cache when there is
one, else one atomic upsert in cliedesk_pro_api_rate. Usage is kept per day × key × route × status class in
cliedesk_pro_api_usage (13 months); cliedesk_pro_api_usage_alert fires once per request burst when a key passes
80 % and 90 % of its limit.
OpenAPI, llms.txt and clients
GET /openapi.jsonbuilds the document of this site at runtime (Actions of add-ons included).sh tools/openapi/generate.sh [test_db]writesdocs/api/openapi.json,docs/api/llms.txtand, throughApiRecordingsTest, the recorded answers intests/phpunit/fixtures/pro/api/recorded.json(CLIEDESK_UPDATE_SNAPSHOTS=1).OpenApiTestfails when the document no longer validates as OpenAPI 3.1, does not match the registered routes one to one, or differs from the snapshot.- Clients (not published yet):
clients/php/CliedeskClient.php(one file, PHP 8.1+, no dependencies) andclients/ts/src/index.ts(fetch-based). Both send the key, use 10 s / 30 s timeouts, retry only retryable errors with jittered backoff (Retry-After wins, 5 attempts) with one Idempotency-Key, follow cursors and throw a typed error withretryable. Contract tests:tests/phpunit/pro/api/PhpClientContractTest.phpandcliedesk-pro/app/__tests__/ApiClient.test.ts.
WP-CLI
wp cliedesk api key create --name=<n> --owner=<user> [--scopes=a,b | --preset=read_only|helpdesk|full]
[--products=1,2] [--ips=203.0.113.0/24] [--expires=<days>|never --yes] [--test] [--format=json]
wp cliedesk api key list [--format=json]
wp cliedesk api key revoke <key_id> [--reason=<code>] [--format=json]
Commands exit 0 on success and 1 on failure. They run the same audited Actions as the settings screen.
Stability policy
v1is additive only: new endpoints, optional fields and parameters, response fields, enum values in fields documented as extensible, error and warning codes, headers, and relaxed limits.- Breaking changes (removing or renaming anything, changing a type or meaning, tightening validation, changing error
codes, pagination or authentication) ship only in a new namespace
cliedesk-api/v2besidev1. - A deprecated element keeps working for at least 12 months after the notice. Its answers carry
Deprecation,Sunsetand aDEPRECATED_*warning. - Each additive change gets a revision date:
GET /returnsrevision, OpenAPIinfo.versionis1.<n>.0 (rev <date>). Changes are listed in the changelog. - Clients must ignore unknown fields, enum values and event types, branch on
error.code, and follownext_cursor.