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.typeishuman,ai,rule,api,customerorsystem.data.objectis 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.objectkeeps ids and core fields anddata.changeslists field names only.data.changeshas the before and after values only when personal data is on and the owner has See personal data (cliedesk_view_pii). audit.entry_created:summaryandactor_label(they can hold names and emails) are filled only with personal data on and an owner withcliedesk_view_pii; otherwise they arenull.actorhastypeandidonly.- 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_logthat 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 (payloadis empty andpayloadHiddenistrue). - A delivery whose secret cannot be read from the vault is not sent unsigned: it fails with the error
secret unavailable. contact.erasedhasdata.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-Afterup 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
pauseFailuresfinal failures (default 1) and no success forpauseHours(default 24), the subscription isauto_paused, admins and the owner get a notification (cliedesk_notify, kindrule_notice), Setup Health shows a warning and the actioncliedesk_pro_webhook_pausedfires. New deliveries areheld(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 jobcliedesk_pro_webhook_deliveries_purge). - Outbound HTTP goes through Free’s
SafeHttp: HTTPS only (HTTP only on alocalordevelopmentsite), 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 theapproval.executedevent). 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.webhookthemselves 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:
typeisworkflow.webhook,data.objectis{"object": "workflow_message", "rule_id": …, "conversation_id": …}and your JSON isdata.body. So a rule cannot send something that looks likecontact.erasedor 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}arenull.
Inbound
POST /wp-json/cliedesk-api/v1/hooks/{inb_…}. A public-by-design route: it authenticates inside the callback.
- 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.
- Check:
cliedesk_hmac(Cliedesk-Signature, as above) ortoken(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 is401 INVALID_CREDENTIALS, logged with the IP and no body. - 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
tand body again answer200 {"status":"duplicate"}, even with a newIdempotency-Key(that header is not signed). Then dedupe on theIdempotency-Keyheader, else the SHA-256 of the body: a repeat answers200 {"status":"duplicate"}. 202 {"status":"accepted","id":"…"}; the jobcliedesk_pro_inbound_processmaps 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, thenfailed; 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).