Skip to content

Black Friday:

60% off

Cliedesk Pro

Every plan, every site count. Ends Cyber Monday.

00 days 00 hrs 00 min 00 sec
Claim 60% off

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 with cdk_test_. Only a SHA-256 hash of the key is stored. The key is shown once (Settings → Developer → API keys, or wp cliedesk api key create).
  • Send it as Authorization: Bearer cdk_live_…. Hosts that strip that header: use X-Cliedesk-Key: cdk_live_…. Never put a key in the URL: such a request gets 400 CREDENTIALS_IN_URL and 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 create is not limited.
  • The product restriction is deny by default (CliedeskPro\Modules\Api\ProductGate). Every record reference in the input is checked: the path id, conversation_id(s), source_ids (merge), conversation number, contact_id(s), source_id, entity + entity_id (tags), product_id(s) (create, move). A record outside the products is 404. Search and reports are limited to the key’s products. A restricted key gets 403 API_PRODUCT_RESTRICTED for 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 through POST /actions/{name}).
  • API keys, OAuth tokens, AI and rule actors cannot send notes or replies with macro steps (macro.steps, and cc / then on a reply): 422 MACRO_STEPS_NEED_PERSON. Send the message, then make the changes with their own requests (or the macro_run MCP 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 same 401 INVALID_CREDENTIALS; expired keys 401 CREDENTIALS_EXPIRED; keys of owners who lost access 401 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: data is {object: "dry_run", action, allowed: true, input} with the warning DRY_RUN_NOT_EXECUTED. No Action code runs, so nothing is written, sent, queued or announced. Both answers have meta.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 (with with_total=true).
  • Error: { "error": { "code", "message", "request_id", "details" } } with UPPER_SNAKE codes and details.retryable. Branch on code, never on message.
  • Every answer: X-Request-Id (send your own, up to 64 [A-Za-z0-9_-]), Cache-Control: no-store, private, and on authenticated answers X-RateLimit-Limit, -Remaining, -Reset, -Cost.
  • Bodies are JSON (415 UNSUPPORTED_MEDIA_TYPE otherwise), at most 1 MiB. Unknown fields are refused by the Action schema (400 VALIDATION_FAILED).
  • Idempotency-Key on POST/PATCH/DELETE replays the stored answer (Idempotent-Replayed: true); the same key with another body is 409 IDEMPOTENCY_KEY_REUSED. Bulk endpoints require it (400 IDEMPOTENCY_KEY_REQUIRED). The store is Free’s idempotency table with the scope api:{key}.
  • Single records send ETag: "v12"; send If-Match: "v12" on writes, a changed record answers 412 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 flagged public_api with an api_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/bulk answer 202 with the job and Location: /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 answer 404). Each operation also needs the scope of its single endpoint: reply and macro messages:write, note notes:write, trash conversations:delete / contacts:delete, tags tags:write, org_add organizations:write.

  • Add-ons add endpoints with the cliedesk_pro_api_endpoints filter (method, path, action, scope, tag, summary).

  • AI (Api\AiEndpoints; the routes and the scopes ai:read and ai:read_traces exist only while the Pro AI module runs, else 404 and the scopes cannot be given): GET /ai/profiles and GET /ai/playbooks (ai:read, metadata only: no goal text, data access, caps, tools, trigger text or steps; the owner needs Manage AI) and GET /ai/traces/{uid} (ai:read_traces, the redacted trace; the owner needs cliedesk_view_ai_traces). ai:read also lets a key receive ai.* webhook and stream events. The headless AI agent adds /ai/agent/* (ai-agent-channels.md).

  • Surveys (surveys.md): GET /survey-responses (filters kind, survey_id, contact_id, product_id, answered, cursor after), 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 by contact_id, email or conversation_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 as custom.*), POST /contacts/import/preview and POST /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 (filters from, to, actor_type, actor_id, action, object_type, object_id, result, product_id, cursor before), GET /audit-log/verify and GET /read-access-log (scope audit:read; the key owner needs cliedesk_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 with channels: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 (404 otherwise). Other modules use the same check through the cliedesk_pro_api_authorize filter (null = no credential, true = allowed, or a WP_Error; an optional 4th argument is the record written to).

  • cliedesk_pro_api_credential_revoked (action, key:<id> or oauth:<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) and GET /oauth/protected-resource (RFC 9728), also at /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource on pretty-permalink sites. Every 401 carries WWW-Authenticate: Bearer resource_metadata="…".
  • Grant: authorization code with PKCE (code_challenge_method=S256 only) 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/authorize checks the client and the redirect URI (exact match; HTTPS, or http://localhost / 127.0.0.1 for 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 carries code, state and iss.
  • POST /oauth/token (form or JSON): authorization_code (code, redirect_uri, client_id, code_verifier) or refresh_token. Access tokens cdk_oat_… live 60 minutes (pro_api.oauth_access_minutes, 5–60), refresh tokens cdk_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) and POST /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/authorize for 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 use token_endpoint_auth_method=none; confidential clients get a secret once (client_secret_post or client_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 in pro_api.oauth_allowed_origins (empty by default).

Lists, filters and the sync feed

  • limit (1–100, default 25) and an opaque, signed cursor. A cursor used with other filters or changed by hand is 400 INVALID_CURSOR. The order is the stable keyset order of the Free list, with no result cap.
  • filter[field][op]=value with eq, ne, in, nin (comma list) and exists from 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.
  • sort from the allowlist (400 INVALID_SORT), fields=a,b (top-level fields, 400 UNKNOWN_FIELD), expand=contact on conversations (needs contacts:read, else 403 for the whole request).
  • GET /sync/{object} (conversations, contacts, organizations; scope events:read plus the object’s read scope, e.g. conversations:read) returns every object changed after the cursor, oldest first, with id, version, change time and deleted. The cursor comes back on every page, also the last, so store it and ask again later. Start from a time with filter[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.json builds the document of this site at runtime (Actions of add-ons included).
  • sh tools/openapi/generate.sh [test_db] writes docs/api/openapi.json, docs/api/llms.txt and, through ApiRecordingsTest, the recorded answers in tests/phpunit/fixtures/pro/api/recorded.json (CLIEDESK_UPDATE_SNAPSHOTS=1). OpenApiTest fails 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) and clients/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 with retryable. Contract tests: tests/phpunit/pro/api/PhpClientContractTest.php and cliedesk-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

  • v1 is 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/v2 beside v1.
  • A deprecated element keeps working for at least 12 months after the notice. Its answers carry Deprecation, Sunset and a DEPRECATED_* warning.
  • Each additive change gets a revision date: GET / returns revision, OpenAPI info.version is 1.<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 follow next_cursor.