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

Webhooks (Cliedesk Pro)

This guide covers a feature of Cliedesk Pro. The free plugin is a complete help desk; see what Pro adds.

Outbound webhooks, the delivery log, retries and replay and inbound webhooks. The spec is cliedesk-pro/src/Modules/Webhooks/. Without a licensed Cliedesk Pro nothing is sent and the inbound URLs return WordPress’s 404 rest_no_route.

Outbound: the request

Each delivery is a POST with a JSON body:

{
  "id": "evt_01J9ZT8…",
  "object": "event",
  "type": "conversation.created",
  "api_version": "v1",
  "created_at": "2026-10-16T09:42:10.004Z",
  "actor": { "type": "human", "id": 7 },
  "product_id": 3,
  "data": { "object": { "object": "conversation", "id": 1042, "…": "…" }, "changes": {} },
  "meta": { "bulk_job_id": null, "data_truncated": false }
}
  • actor.type is human, ai, rule, api, customer or system.
  • data.object is the record as the subscription’s owner sees it (the same read Action as the app, with the owner’s permissions and PII masking). The owner gets no event for records they cannot read.
  • With Include personal data off, data.object keeps ids and core fields and data.changes lists field names only. data.changes has the before and after values only when personal data is on and the owner has See personal data (cliedesk_view_pii).
  • audit.entry_created: summary and actor_label (they can hold names and emails) are filled only with personal data on and an owner with cliedesk_view_pii; otherwise they are null. actor has type and id only.
  • Payloads use the owner’s rights, so a person who may see less than the owner (any of cliedesk_access, cliedesk_view_conversations, cliedesk_view_contacts, cliedesk_view_pii, cliedesk_view_audit_log that the owner has, or fewer products) cannot change the URL, events, filters or Include personal data of that webhook (403), and sees the delivery log without payloads (payload is empty and payloadHidden is true).
  • A delivery whose secret cannot be read from the vault is not sent unsigned: it fails with the error secret unavailable.
  • contact.erased has data.object = {"id": 12, "object": "contact"} and no personal data.
  • The payload is made when the event happens and stored, so retries and replays send the same bytes. Bodies over 256 KiB are cut to ids with meta.data_truncated = true.

Headers:

Content-Type: application/json
User-Agent: Cliedesk-Webhooks/1.0 (+https://cliedesk.com)
Cliedesk-Webhook-Id: evt_…        same on every retry and replay: dedupe on it
Idempotency-Key: evt_…            the same value
Cliedesk-Delivery-Id: dlv_…       one per delivery row (a replay gets a new one)
Cliedesk-Delivery-Attempt: 3
Cliedesk-Signature: t=1760607730,v1=5257a8…[,v1=… during a secret rotation]

Check the signature

v1 = hex( HMAC-SHA256( secret, "{t}.{raw body}" ) ). Check the raw bytes before you parse them, compare in constant time, accept any v1, and refuse t more than 300 seconds away from now.

function cliedesk_signature_ok( string $header, string $body, string $secret ): bool {
	$t = null;
	$sigs = array();
	foreach ( explode( ',', $header ) as $part ) {
		[ $k, $v ] = array_pad( explode( '=', trim( $part ), 2 ), 2, '' );
		if ( 't' === $k ) { $t = (int) $v; }
		if ( 'v1' === $k ) { $sigs[] = $v; }
	}
	if ( null === $t || abs( time() - $t ) > 300 ) {
		return false;
	}
	$want = hash_hmac( 'sha256', $t . '.' . $body, $secret );
	foreach ( $sigs as $sig ) {
		if ( hash_equals( $want, $sig ) ) {
			return true;
		}
	}
	return false;
}
const crypto = require( 'node:crypto' );
function signatureOk( header, rawBody, secret ) {
	const parts = Object.groupBy( header.split( ',' ).map( ( p ) => p.trim().split( '=' ) ), ( [ k ] ) => k );
	const t = Number( parts.t?.[ 0 ]?.[ 1 ] );
	if ( ! t || Math.abs( Date.now() / 1000 - t ) > 300 ) return false;
	const want = crypto.createHmac( 'sha256', secret ).update( `${ t }.${ rawBody }` ).digest();
	return ( parts.v1 ?? [] ).some( ( [ , v ] ) => {
		const got = Buffer.from( v, 'hex' );
		return got.length === want.length && crypto.timingSafeEqual( got, want );
	} );
}

Answer 2xx quickly and do the work later. Duplicates can happen (at-least-once); the order is not guaranteed.

Retries, pause and the log

  • Retry on network errors, timeouts (connect 5 s, total 10 s), 408, 425, 429 and 5xx. Default 8 tries at 0, 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h after the first, ±10 % jitter (not on the last try). Per subscription: 2–8 tries, last try 1–72 h after the first. A Retry-After up to 1 hour on a 429 replaces the next delay.
  • No retry on 3xx (redirects are never followed) and other 4xx. 410 Gone turns the subscription off.
  • Auto-pause (default on): after pauseFailures final failures (default 1) and no success for pauseHours (default 24), the subscription is auto_paused, admins and the owner get a notification (cliedesk_notify, kind rule_notice), Setup Health shows a warning and the action cliedesk_pro_webhook_paused fires. New deliveries are held (at most 10,000). Resume sends them in order, ten per second.
  • The log keeps request headers, the HTTP code, the first 2 KiB of the response, the duration and the error, 30 days by default (setting pro_webhooks.delivery_log_days, 7–90; daily job cliedesk_pro_webhook_deliveries_purge).
  • Outbound HTTP goes through Free’s SafeHttp: HTTPS only (HTTP only on a local or development site), ports 443 and 8443, private, loopback, link-local and metadata addresses refused at save time and at every delivery, DNS pinned.

Events

conversation.created, .updated, .status_changed, .assigned, .tags_changed, .merged, .split, .trashed, .restored, .spam_marked, .sla_warning, .sla_breached; message.created, message.delivery_failed, note.created; contact.created, .updated, .merged, .trashed, .erased; organization.created, .updated, .deleted; article.published, .updated, .unpublished; survey.response_received; approval.requested, approval.decided; audit.entry_created (read from the audit log every minute); webhook.test. Pro surveys also send survey.response_received for NPS, CES and microsurvey answers and for answers written through the Public API (surveys.md); report.scheduled carries a scheduled report’s summary and a 7-day file link (custom-reports.md). A subscription can use a prefix (conversation.*) or *. Add types with the filter cliedesk_pro_webhook_events.

AI events (Webhooks\AiEvents, only while the Pro AI agent runs):

  • ai.action_executed: a live AI turn ran an Action that changes something (data.action, data.autonomy: auto), or a person approved the agent’s request and it ran (autonomy: approval, from the approval.executed event). Reads, suggestions, calls waiting for the customer’s confirmation, refused or failed calls, and test or shadow turns are not sent.
  • ai.handoff_requested: the agent handed a conversation to people (data.reason: customer_asked, policy, low_confidence, needs_approval, budget_cap or error).

Both carry data.object (the conversation rendered as the owner, like the other conversation events) and use the conversation’s filters. The tool input, its result and the customer’s text are never sent.

Through the Public API (/webhooks, and REST hooks /hooks) a key subscribes only to event types its read scopes cover and never to * (403 otherwise). Catalog::api_scopes() expands a pattern (ai.*) to every type it covers and each type needs its scope: contact.* needs contacts:read; AI events carry the conversation, so ai.action_executed needs ai:read and conversations:read, and ai.handoff_requested needs conversations:read. A type without a read scope (report.scheduled, add-on types) is refused; webhook.test needs none (M11 H1, H12).

Filters: brandIds, productIds, channels and conversationTypes; AND between keys, OR within a key. Records without a product (contacts, articles) pass the brand and product filters.

The “Send webhook” rule step

ext.send_webhook (registered with cliedesk_register_action): webhook is the subscription id (its URL, secret, retries and log are used) and body is JSON with typed tokens as values: {conversation.id}, {conversation.number}, {conversation.subject}, {conversation.status}, {conversation.priority}, {conversation.product_id}, {contact.id}, {contact.email}, {contact.name}, {rule.id}, {now}. Each token becomes a JSON value. The step returns {vars.webhookDelivery}.

  • Only subscriptions that list the event workflow.webhook themselves take the step (* does not opt in). It is not a catalogue event: the stream and the automation platforms never get it.
  • The body is always sent inside the normal envelope: type is workflow.webhook, data.object is {"object": "workflow_message", "rule_id": …, "conversation_id": …} and your JSON is data.body. So a rule cannot send something that looks like contact.erased or another event.
  • The subscription’s filters must match the conversation, and its owner must be able to see it.
  • With Include personal data off (or an owner without cliedesk_view_pii), {contact.email}, {contact.name} and {conversation.subject} are null.

Inbound

POST /wp-json/cliedesk-api/v1/hooks/{inb_…}. A public-by-design route: it authenticates inside the callback.

  1. Size up to 1 MiB (else 413), rate limit 120 per minute per endpoint and IP (429). An IP with 20 failed checks in a minute is refused for a minute (429), before any check.
  2. Check: cliedesk_hmac (Cliedesk-Signature, as above) or token (X-Cliedesk-Token, weaker: it has no time window and no replay protection beyond the dedupe in step 3). Both secrets work during a rotation. A failure is 401 INVALID_CREDENTIALS, logged with the IP and no body.
  3. Only checked calls count against the 600 per minute of the endpoint, so unsigned calls cannot block the sender. A signed call is taken once: the same signed t and body again answer 200 {"status":"duplicate"}, even with a new Idempotency-Key (that header is not signed). Then dedupe on the Idempotency-Key header, else the SHA-256 of the body: a repeat answers 200 {"status":"duplicate"}.
  4. 202 {"status":"accepted","id":"…"}; the job cliedesk_pro_inbound_process maps the body and starts the rule (once per call). A failure (no conversation, a required field missing, a wrong type) is retried at 1 min, 5 min, 30 min, 2 h and 6 h, then failed; it can be replayed.

The mapping finds the conversation (conversation.path with by = id or number, or conversation.emailPath for the customer’s newest open conversation) and typed fields (text, number, bool, date, email) from simple JSON paths such as $.order.total or $.items[0].sku. The rule’s steps use them as {vars.name}; {vars.webhookCall} and {vars.webhookEndpoint} are set too. Calls are kept 30 days (setting pro_webhooks.inbound_log_days, 7–90; daily job cliedesk_pro_inbound_calls_purge).

Other Pro modules add endpoint kinds (product access sources, custom channels) with the filter cliedesk_pro_inbound_kinds: kind => callable( array $endpoint, array $payload, array $values, int $call ): array.

Internal API

On cliedesk/v1 (cookie and nonce, cliedesk_manage_settings; inbound endpoints cliedesk_manage_automation): webhooks/events, webhooks (GET, POST), webhooks/{id} (PATCH, DELETE), webhooks/{id}/rotate, /pause, /resume, /test, /deliveries, /replay-failed, webhook-deliveries/{id} and /replay, inbound-endpoints (GET, POST), inbound-endpoints/{id} (PATCH, DELETE), /rotate, /test, /calls, inbound-calls/{id}/replay. Secrets are returned only in the answer that made them.

Tables

cliedesk_pro_webhooks, cliedesk_pro_webhook_deliveries (payload gzcompressed), cliedesk_pro_inbound_endpoints, cliedesk_pro_inbound_calls. Secrets live in Free’s vault (pro_webhooks.whk.{uid}, pro_webhooks.inb.{uid}, kind webhook_secret).