Skip to content

Developers

Webhooks: being told the moment something happens

The event catalogue, the payload envelope, how to verify the signature, and how retries and failures work

4 min readUpdated 27 September 2026

A webhook endpoint is an HTTPS address on your server that ClientTurn POSTs to whenever an event you subscribed to happens. To add one, follow Setting up a webhook.

The request

Every delivery is a POST with a JSON body and these headers:

HeaderValue
content-typeapplication/json
clientturn-signaturet=<unix seconds>,v1=<hex signature>
clientturn-event-idThe event's ID, stable across retries
clientturn-event-typeFor example lead.created
clientturn-delivery-attempt1 for the first attempt, then 2, 3…

The body is always the same envelope:

{
  "id": "4f0c…",
  "type": "meeting.booked",
  "created_at": "2026-09-26T09:14:03.120Z",
  "data": { … }
}

Verify the signature

The signature is an HMAC-SHA256, using your endpoint's signing secret, of the timestamp, a full stop, and the raw request body. A Node.js example:

import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody, header, secret, toleranceSeconds = 300) {
  if (!header) return false;
  let t = null;
  const candidates = [];
  for (const part of header.split(",")) {
    const [key, value] = part.split("=", 2);
    if (key === "t") t = Number(value);
    if (key === "v1") candidates.push(value);
  }
  if (!Number.isFinite(t) || candidates.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;

  const expected = Buffer.from(
    createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"),
  );
  return candidates.some((candidate) => {
    const offered = Buffer.from(candidate);
    return offered.length === expected.length && timingSafeEqual(offered, expected);
  });
}

The event catalogue

An event appears here only if ClientTurn actually sends it.

TypeSent when
lead.createdA new lead arrived from any source
lead.touchedSomeone who is already a lead enquired again and was matched, not duplicated
lead.qualifiedA lead finished qualification. Carries the outcome and the answers
lead.status_changedA lead moved status, whoever moved it: a person, Copilot, an agent or your own software
lead.handover_requiredFollow-up stopped and a person is needed
lead.scoredA lead received a new score
score.changedA new score moved the lead to a different grade (A to D)
message.receivedAn inbound message arrived on any channel
reply.receivedA lead replied, once per inbound message
booking.createdAn appointment was booked
meeting.bookedA meeting was confirmed by a calendar or a person
meeting.pendingA lead chose a time no calendar could confirm; it waits for someone to confirm or decline
meeting.cancelledA booked or requested meeting was cancelled
meeting.no_showA meeting was marked as a no-show
contact.unsubscribedA person opted out, on one channel or all of them
contact.suppressedAn address or number was blocked for another reason: a bounce, a complaint, an invalid number or a manual block
opportunity.createdA lead became a sales opportunity. Carries the lead, the starting stage and the sales motion
opportunity.wonAn opportunity was closed as won. Carries the lead and the reason given
opportunity.lostAn opportunity was closed as lost. Carries the lead and the reason given
ai.escalatedThe conversation assistant handed a lead to a person, or asked a person for help in the background while it keeps the conversation (an assist request, such as confirming a price or the brief for a booked meeting). Read the hand-over in the app to tell which

What data contains

For the events delivered through ClientTurn's event outbox, data also carries subject_type, subject_id and occurred_at.

TypeFields in data
lead.touchedlead_id, touch_id, outcome (MERGED or SUPPRESSED), source_type, source, contact fields
lead.scored, score.changedlead_id, score_id, total, grade, previous_grade, confidence, trigger_event, scoring_version
reply.receivedmessage_id, lead_id, channel, classification (often null when the reply first arrives)
meeting.*booking_id, lead_id, provider, status, previous_status, starts_at, ends_at
contact.unsubscribed, contact.suppressedlead_id (when the address belongs to a lead), suppression_id, channel (ALL or one channel), reason, source
opportunity.createdlead_id, stage, motion
opportunity.won, opportunity.lostlead_id, reason
ai.escalatedhandoff_id, lead_id, conversation_id, reason, priority

The Test button sends a separate event type, endpoint.test, never a fake lead.created, so your handler can tell a drill from the real thing.

Handling duplicates

Use the event id (also in clientturn-event-id) to make your handler idempotent. It is the same on every retry, and the same event is never queued twice for one endpoint.

Retries and failures

  • Answer with any 2xx within 10 seconds to acknowledge.
  • A failed delivery is retried up to six more times, after 30 seconds, 5 minutes, 30 minutes, 2 hours, 8 hours and 12 hours: seven attempts in all, over about 22.6 hours. While it is waiting for the next attempt, the delivery shows Retrying. After the last attempt it is marked Gave up.
  • A 4xx answer is not retried, except 408 and 429, because repeating a request your server rejected will not change the answer.
  • Redirects are not followed. Point the endpoint at the final address.
  • After 20 failed deliveries in a row, the endpoint is switched off automatically and marked Disabled, with the reason. Fix the problem and switch it back on.

The Recent deliveries log under your endpoints shows each delivery's status (Queued, Retrying, Delivered, Gave up or Cancelled), what was sent, what your server answered and when the next attempt is due. Your own software can read the same log with GET /api/v1/events.

The signing secret

The secret starts whsec_ and is shown once, when you create the endpoint and when you rotate it. Rotating takes effect immediately with no overlap, so update your server at the same time.

Screenshots

The Webhooks panel with one endpoint and its recent deliveries marked Retrying, Delivered and Gave up
Figure 1What was sent and what your server answered

Still stuck?

Customers can raise a ticket from the help button inside the app. Not a customer yet? Talk to us.

Contact us