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
3xxcounts 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:
{
"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.
{
"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.
{
"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:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Clivly-Webhooks/1 |
X-Clivly-Event-Id | The event id. The same on every retry — dedupe on it. |
X-Clivly-Event-Type | e.g. conversation.visitor_message |
X-Clivly-Timestamp | Unix seconds when this attempt was signed. |
X-Clivly-Signature | t=<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:
- Read the raw request body. Re-serialised JSON will not match.
- Take
tand everyv1value fromX-Clivly-Signature. Accept anyv1that matches, so a future secret rotation can sign with two keys at once. - Reject the request if
tis more than 300 seconds (five minutes) away from your clock, either way. - 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.
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
2xxwithin 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.