Skip to content

Agents

Webhooks

The two agent events, their payloads, how Clivly signs each delivery, and the retry and dedupe rules your receiver should follow.

An agent can be given one webhook URL. Clivly POSTs to it when something happens that the agent may want to act on, so the agent does not have to poll. Webhooks are optional: an agent without one still works through the MCP tools.

Register and verify the URL

Set the URL when you create the agent, or later from the agent's Edit dialog in Settings → Agents.

  • HTTPS only, on the default or an explicit port, with no credentials in the URL.
  • A public domain name. IP addresses, localhost, single-label and internal-only names are refused, and every address the name resolves to must be public. The check runs when you save the URL and again before every delivery.
  • One URL per agent. Its domain becomes the allowlist entry: deliveries go only to that verified host, and redirects are never followed — a 3xx counts as a failed attempt.

Saving a URL returns a signing secret starting whsec_. Like the token it is shown once; copy it into your receiver's configuration. Replacing the URL issues a new secret.

A new URL receives nothing until it is verified. Verify sends a signed webhook.ping and activates the webhook only if your receiver answers 2xx. Changing the URL puts it back to unverified, so a verified host can never be swapped for one nobody checked. Re-verifying an active webhook that now fails demotes it until it passes again.

The ping has the same envelope as a real event, with empty data:

webhook.ping
{
  "id": "evt_ping_4f1c…",
  "type": "webhook.ping",
  "orgId": "…",
  "agentId": "…",
  "createdAt": "2026-10-19T09:30:00.000Z",
  "data": {}
}

Events

v1 has two event types. Payloads carry ids plus a short preview, never a full message or record: an agent that needs the text reads it through its MCP tools, which apply its scopes.

The preview is plain text on one line — tags and invisible characters removed, whitespace collapsed — and at most 200 characters.

conversation.visitor_message

A visitor sent a message in a chat widget conversation.

conversation.visitor_message
{
  "id": "evt_9b2e…",
  "type": "conversation.visitor_message",
  "orgId": "…",
  "agentId": "…",
  "createdAt": "2026-10-19T09:30:00.000Z",
  "data": {
    "conversationId": "…",
    "contactId": "…",
    "messageId": "…",
    "preview": "Hi, can I move my renewal to next month?"
  }
}

contactId is null when the visitor is not linked to a contact.

While this event is outstanding, the agent's writes go to approval. See Visitor messages hold writes for review.

draft.decided

A person sent or discarded an email draft. An approved agent proposal becomes a draft in the approver's queue, so for an agent's email this is the moment the email actually went out — or didn't. Approving or rejecting the proposal itself sends no event.

draft.decided
{
  "id": "evt_51ad…",
  "type": "draft.decided",
  "orgId": "…",
  "agentId": "…",
  "createdAt": "2026-10-19T09:41:00.000Z",
  "data": {
    "draftId": "…",
    "approvalId": "…",
    "contactId": "…",
    "decision": "approved",
    "decidedBy": "…",
    "emailId": "…",
    "preview": "Following up on your renewal"
  }
}

decision is approved when the draft was sent, with the sent email's emailId, and rejected when it was discarded, with emailId null. draftId is the draft's own id. The preview is the draft's subject.

approvalId is the id the agent received in its pending_approval result when the draft came from an approved agent proposal, and null for drafts written by Ask your CRM's own assistant. This is how an agent ties the outcome back to its proposal.

Like every event, it goes to every subscribed agent in the workspace, not only the one that proposed the draft. Match approvalId against the ids you hold and ignore the rest.

Who receives an event

Each event is queued once per agent whose webhook is verified, whose own status is active and unexpired, while agent access is on and not paused for the whole workspace. Each agent gets its own event id.

Headers and signature

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

HeaderValue
Content-Typeapplication/json
User-AgentClivly-Webhooks/1
X-Clivly-Event-IdThe event id. The same on every retry — dedupe on it.
X-Clivly-Event-Typee.g. conversation.visitor_message
X-Clivly-TimestampUnix seconds when this attempt was signed.
X-Clivly-Signaturet=<unix seconds>,v1=<hex signature>

The signature is a hex HMAC-SHA256, keyed with the whole signing secret (including the whsec_ prefix), over the string <t>.<raw body>.

To verify a delivery:

  1. Read the raw request body. Re-serialised JSON will not match.
  2. Take t and every v1 value from X-Clivly-Signature. Accept any v1 that matches, so a future secret rotation can sign with two keys at once.
  3. Reject the request if t is more than 300 seconds (five minutes) away from your clock, either way.
  4. Compute the HMAC and compare in constant time.

Each attempt is signed afresh, so a retry has a new t and signature but the same event id.

verify-clivly-webhook.ts
import { createHmac, timingSafeEqual } from "node:crypto";

const REPLAY_WINDOW_SECONDS = 300;

export function verifyClivlyWebhook(
  rawBody: string,
  signatureHeader: string | undefined,
  secret: string,
): boolean {
  if (!signatureHeader) return false;

  let timestamp: number | null = null;
  const signatures: string[] = [];
  for (const part of signatureHeader.split(",")) {
    const [key, value] = part.trim().split("=", 2);
    if (key === "t" && value) timestamp = Number(value);
    else if (key === "v1" && value) signatures.push(value);
  }
  if (timestamp === null || !Number.isInteger(timestamp) || signatures.length === 0) {
    return false;
  }

  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > REPLAY_WINDOW_SECONDS) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  const expectedBytes = Buffer.from(expected, "utf8");

  return signatures.some((candidate) => {
    const candidateBytes = Buffer.from(candidate, "utf8");
    return (
      candidateBytes.length === expectedBytes.length &&
      timingSafeEqual(candidateBytes, expectedBytes)
    );
  });
}

Delivery, retries and dedupe

  • At least once. A receiver can see the same event more than once — a response lost after you processed it, a worker stopped mid-call. Dedupe on X-Clivly-Event-Id.
  • Not instant. Deliveries run on a once-a-minute schedule, so the first attempt usually arrives within a minute or two of the event.
  • Success is any 2xx within 10 seconds. Anything else — another status, a redirect, a timeout, a network error — is a failed attempt.
  • Retries back off exponentially: about one minute after the first failure, doubling each time, capped at six hours, with jitter (each wait is between half and all of that ceiling) so a recovering receiver is not hit by every queued event at once.
  • Eight attempts, then the event is marked failed and never retried again.
  • An event whose webhook has been disabled or removed, or whose agent has lost access, is dropped rather than delivered.

Answer quickly and do the work afterwards. A receiver that takes longer than ten seconds fails the attempt even if it eventually succeeds — and then gets the event again.