For the agent

Agent hand-off: events, signatures and replies

Three signed events carry the visitor’s whole journey to your agent; one API call sends the answer. This page is the contract, and the same text is served as plain Markdown for agents that prefer it.

View as MarkdownWhat it does, for ownersAPI reference

events: visitor.engaged, chat.message, visitor.identified · headers: X-Desk-Signature, X-Desk-Event, X-Desk-Delivery-Id

What this is

DeskCrew opens the website chat for a visitor on the owner’s rules, records the visitor’s journey on that site, and hands the conversation to the business’s own agent. The agent receives three signed webhook events, each carrying the whole journey, and answers by posting to one reply URL that is included in every event. Nothing else needs to be looked up.

This page is the whole contract. The same text is served as Markdown at https://deskcrew.io/docs/agent-handoff/index.md for agents that prefer plain text. The owner-facing description is at https://deskcrew.io/proactive-chat.

The three events

  • visitor.engaged: a proactive rule fired and a chat ticket was opened (or reused) for the visitor. data.rule names the rule. Sent only when the workspace has handed its chats to its own agent.
  • chat.message: the visitor sent a message in the website chat. data.message carries the body and time. Agent replies never fire it, so an agent that answers every chat.message cannot trigger itself.
  • visitor.identified: the visitor gave an email in the chat. data.customer is now set (email, name when known, number of other tickets in this workspace) and the visitor is linked to that customer record.

All three carry the same data shape (below). They are delivered immediately when they happen, not on a periodic schedule, to every endpoint of the workspace that subscribes to them.

Delivery: headers, envelope, timing

Every delivery is an HTTPS POST with content-type application/json and these headers:

  • X-Desk-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>. See the next section.
  • X-Desk-Event: the event name, for example chat.message.
  • X-Desk-Delivery-Id: the delivery row id, unique per endpoint and event. Retries of the same delivery carry the same id.
  • Your own key, if the owner entered one when connecting: sent under the header name they chose. Authorization is the default and a bare key typed there goes out as "Bearer <key>"; X-Api-Key, X-Auth-Token or a custom name is sent exactly as typed.
json
{ "event": "chat.message", "delivery_id": 48214, "data": { ... } }

Answer any 2xx within 4 seconds. On a non-2xx answer, a timeout or a connection error, the event is retried once after 1 second, then handed to the regular webhook retry schedule with growing backoff. Dedupe on delivery_id: the same delivery can reach you more than once, and the same logical change (one visitor message) never produces two different delivery ids for one endpoint.

Do your work after you have answered. A reply posted inside the 4 second window is fine, but a slow model call should not hold the response.

Verifying the signature

The signing string is the timestamp from the header, a dot, and the raw request body exactly as received. The signature is HMAC-SHA256 with your endpoint secret (the whsec_ value shown once when the endpoint was created), hex encoded. Reject a timestamp more than 300 seconds from your clock. Compare in constant time.

JavaScript (Node runtime, no dependencies)
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody is the request body as received, byte for byte. Parse JSON only after verifying.
export function verifyDeskSignature(secret, header, rawBody, nowSec = Math.floor(Date.now() / 1000)) {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(nowSec - t) > 300) return false; // 5 minute replay window
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`, "utf8").digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(String(parts.v1 ?? ""), "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

// Example handler (any framework; the point is reading the raw body first):
//   const ok = verifyDeskSignature(process.env.DESK_WEBHOOK_SECRET, req.headers["x-desk-signature"], rawBody);
//   if (!ok) return res.status(401).end();
//   const { event, delivery_id, data } = JSON.parse(rawBody);

Read the raw body before any JSON parsing or body middleware, because re-serialised JSON will not match. The secret is per endpoint; a workspace with two endpoints has two secrets.

The data shape

data is the same object for all three events. Sections that depend on journey capture (visitor, currentPage, visit, previousVisits) are null or empty when the owner has journey capture off or the visitor’s site set window.DeskCrewConsent = false; the ticket and the conversation are always present.

TypeScript shape
data: {
  event: "visitor.engaged" | "chat.message" | "visitor.identified"
  workspace: { id: number, slug: string | null, name: string | null }
  ticket: { id: number, status: string, url: string }
  rule: { id: string, name: string } | null            // visitor.engaged only
  message: { body: string, at: string } | null         // chat.message only
  visitor: {
    vid: string, returning: boolean, visitCount: number,
    firstSeenAt: string | null, country: string | null, device: string | null,
    language: string | null, timezone: string | null, localTime: string | null  // "HH:MM"
  } | null
  customer: { email: string, name: string | null, pastTickets: number } | null
  currentPage: { path: string, title: string, secondsOnPage: number } | null
  visit: {
    landingUrl: string | null, referrer: string | null, utm: object,
    pages: { path: string, title: string, seconds: number, scroll: number }[],   // scroll 0..100
    clicks: { text: string, href: string | null, path: string, at: string | null }[]
  } | null
  previousVisits: { startedAt: string | null, pageCount: number, firstPath: string | null, source: string }[]  // newest first, up to 5
  conversation: { role: "customer" | "agent" | "system", body: string, at: string }[]  // oldest first, last 20 public messages
  reply: { url: string, method: "POST", body: { body: string, mode: "send", askEmail: boolean } }
}

Field notes:

  • workspace: the DeskCrew workspace (one per website). Use workspace.id to pick the right secret and API key when one agent serves several sites.
  • ticket.url: the conversation in the owner’s dashboard. ticket.status is the ticket status, usually active.
  • visitor.returning is visitCount > 1. localTime is HH:MM in the visitor’s timezone at send time, or null without a timezone.
  • visit.utm holds the utm_source, utm_medium, utm_campaign, utm_term and utm_content tags present on the landing URL, and gclid or fbclid presence as booleans, never their values. No other query parameters are ever recorded.
  • visit.pages is in order of viewing; the last entry is the current page. seconds on the current page is counted up to the moment of sending. Up to 100 pages and 100 clicks per visit.
  • visit.clicks: the visible text of the link or button (first 80 characters) and its href, nothing typed. Keystrokes and form fields are never recorded.
  • previousVisits: the visitor’s last 5 earlier visits, newest first, with their source (utm_source, else the referrer host, else direct).
  • conversation: the last 20 public messages, oldest first. role is customer for the visitor, agent for any reply shown to the visitor (yours, a human’s, or the opener), system for anything else.
  • reply: the URL and body template for your answer. body.body is empty on purpose; fill it in.
Worked example: chat.message, full envelope
{
  "event": "chat.message",
  "delivery_id": 48214,
  "data": {
    "event": "chat.message",
    "workspace": { "id": 12, "slug": "acme", "name": "Acme Outdoor" },
    "ticket": {
      "id": 9041,
      "status": "active",
      "url": "https://deskcrew.io/dashboard/tickets/9041"
    },
    "rule": null,
    "message": { "body": "Does the Alpine 2 fit two people and a dog?", "at": "2026-10-15T14:12:40.000Z" },
    "visitor": {
      "vid": "v_8f2c1a9e4b7d",
      "returning": true,
      "visitCount": 3,
      "firstSeenAt": "2026-10-02T18:04:11.000Z",
      "country": "DE",
      "device": "desktop",
      "language": "de-DE",
      "timezone": "Europe/Berlin",
      "localTime": "16:12"
    },
    "customer": null,
    "currentPage": { "path": "/pricing", "title": "Pricing", "secondsOnPage": 47 },
    "visit": {
      "landingUrl": "https://acme.example/tents/alpine-2",
      "referrer": "https://www.google.com/",
      "utm": { "utm_source": "google", "utm_medium": "cpc", "utm_campaign": "tents-q4" },
      "pages": [
        { "path": "/tents/alpine-2", "title": "Alpine 2 tent", "seconds": 80, "scroll": 90 },
        { "path": "/pricing", "title": "Pricing", "seconds": 47, "scroll": 60 }
      ],
      "clicks": [
        {
          "text": "Compare sizes",
          "href": "https://acme.example/tents/compare",
          "path": "/tents/alpine-2",
          "at": "2026-10-15T14:11:02.000Z"
        }
      ]
    },
    "previousVisits": [
      { "startedAt": "2026-10-09T09:30:00.000Z", "pageCount": 4, "firstPath": "/", "source": "direct" },
      { "startedAt": "2026-10-02T18:04:11.000Z", "pageCount": 2, "firstPath": "/tents", "source": "instagram.com" }
    ],
    "conversation": [
      { "role": "agent", "body": "Hi, I see you are comparing tent sizes. Can I help?", "at": "2026-10-15T14:12:05.000Z" },
      { "role": "customer", "body": "Does the Alpine 2 fit two people and a dog?", "at": "2026-10-15T14:12:40.000Z" }
    ],
    "reply": {
      "url": "https://deskcrew.io/api/v1/tickets/9041/reply",
      "method": "POST",
      "body": { "body": "", "mode": "send", "askEmail": false }
    }
  }
}

How to reply

POST to data.reply.url with a workspace API key that has the tickets:reply scope. The key is minted on the dashboard (API keys page) and is shown once. Keys are per workspace.

HTTP
POST https://deskcrew.io/api/v1/tickets/9041/reply
Authorization: Bearer dk_...
Content-Type: application/json

{ "body": "Yes, the Alpine 2 sleeps two with room at the foot for a medium dog. Want the floor dimensions?", "mode": "send", "askEmail": false }

HTTP 200
{ "data": { "id": 55120, "ticket_id": 9041, "type": "agent_reply", "body": "Yes, the Alpine 2 ...", "source": "agent", "created_at": "2026-10-15T14:12:43.000Z", "mode": "send", "delivered": true } }
  • body: 1 to 8000 characters, plain text. Required.
  • mode: "send" posts the reply to the visitor at once. Omit it or send "draft" and the text lands in the owner’s approval queue instead and never reaches the visitor until a human sends it.
  • askEmail: true (with mode "send") shows the email box under that message in the chat while the visitor has no email on file. When they fill it in you receive visitor.identified. Ignored for drafts.
  • Response: 200 with data.id (the message id), data.ticket_id, data.type "agent_reply", data.mode and data.delivered true. Errors carry a JSON code: 400 invalid_request, 401 unauthorized, 403 forbidden_scope or live_replies_disabled, 404 not_found, 429 rate_limited with a retry-after header.
  • live_replies_disabled means the workspace has not switched on either hand-off for website chats or general live replies for this key; fall back to mode "draft".

Caps

  • Per ticket: 30 replies per 5 minutes on a handed-off chat ticket (3 per 5 minutes on any other ticket). A reply loop on one conversation dies within minutes whatever the daily cap.
  • Per API key: 200 replies per day by default, drafts and sends counted together. The owner can change this number on the dashboard; 0 removes the cap.
  • Both return 429 rate_limited with a retry-after header. The windows are fixed 5 minute and 24 hour windows, so a 429 clears at the next window boundary.

The 5 second rule

When a rule opens the chat, the widget shows a typing indicator and DeskCrew sends visitor.engaged. If no agent reply exists on the ticket 5 seconds later, the server posts the rule’s fixed opener (the text the owner wrote on the rule) as the first message, so the visitor never waits on nothing. Your reply to visitor.engaged therefore has about 5 seconds to be the opening line; a later reply is still delivered as the next message. On chat.message there is no fallback: the visitor waits for you, and while the chat is handed off DeskCrew’s built-in assistant posts nothing on chat tickets. Humans are notified as usual and can answer from the dashboard.

Several websites, one agent

Each website is its own DeskCrew workspace with its own endpoint secret and its own API key. Key your secrets and keys by data.workspace.id: read the id from the body to select the secret, verify the signature with that secret, and reply with that workspace’s key. A key from one workspace cannot reply to another’s tickets (404).

JavaScript
// One agent, several websites: every DeskCrew workspace has its own endpoint secret and its own API key.
const SITES = {
  12: { secret: process.env.ACME_WEBHOOK_SECRET, apiKey: process.env.ACME_API_KEY },
  31: { secret: process.env.NORTH_WEBHOOK_SECRET, apiKey: process.env.NORTH_API_KEY },
};
// Read data.workspace.id from the (not yet trusted) body to pick the secret, verify, then reply with that site's key.

What the owner does on the dashboard

Everything happens on the Proactive chat page of the workspace dashboard:

  • Visitor journeys: turn on Record visitor journeys. Off, the widget sends nothing and visitor, currentPage, visit and previousVisits arrive null or empty.
  • Proactive chat: turn on Open a chat when a rule matches, add rules (name, conditions, opener) and review the excluded paths (checkout, cart and payment by default).
  • Connect your agent: enter the webhook address (https), pick the header name your agent expects a key under (Authorization by default; X-Api-Key, X-Auth-Token or a custom name) and the key itself if it wants one. One click creates the endpoint subscribed to the three events and mints an API key with the tickets:reply scope, named "Proactive chat agent".
  • Give this to your agent: the page then shows one block, with Copy all, holding the webhook secret (whsec_..., verifies X-Desk-Signature), the API key (dk_..., the Bearer key for data.reply.url), the reply URL pattern <origin>/api/v1/tickets/{id}/reply, this setup page’s URL and the three event names. Both secrets are shown once; a second connect mints another key and says so.
  • Who answers chats flips to My own agent on connect, and Save stores it. From then on the three events fire and the built-in assistant is silent on chat tickets.
  • Send test event: pushes a sample visitor.engaged to every connected endpoint with "test": true in data and a ticket id that does not exist, so a reply to it answers 404 and nothing reaches a visitor. The page shows each endpoint’s result: status, HTTP code and the first bytes of the response.
  • Connected agents: every endpoint with its address and status, and Remove to take one out.

Visitor journeys, proactive rules and the hand-off are part of the Pro plan and above; an active trial counts. Below Pro the widget serves no rules and records no journeys, the three events do not fire, and website chats go back to DeskCrew’s built-in assistant, whatever is stored on the page.

Privacy notes for the agent operator

  • The journey is personal data processed for the business that owns the website. Keep it for the conversation; do not resell it or merge it across businesses.
  • Recorded: pages, time on page, scroll depth, link and button clicks, landing page, referrer, UTM tags, device class, language, timezone. Never recorded: keystrokes, form fields, query strings other than UTM tags. Visits are deleted after 90 days on the DeskCrew side.
  • A site that sets window.DeskCrewConsent = false before the widget loads sends no journey; you still receive the ticket and the conversation.
Create a workspaceFetch this page as Markdown