Skip to content

API tokens and webhooks

Enterprise feature (new in 1.19.4, helpdesk). See the Helpdesk overview.

Other tools can work with the helpdesk in two directions:

  • The REST API (/api/helpdesk/v1): a tool reads and updates tickets with an API token, which acts as a desk role you choose, in the teams you choose.
  • Webhooks: CompassDocs tells a tool when tickets, messages and approvals change, by POSTing signed JSON to its address.

Both are set up in Desk settings → API and webhooks (“Connect other tools to the helpdesk: they read and update tickets with a token, and hear about changes through webhooks.”). It needs the desk capability Helpdesk settings (Admins, out of the box).

This API is separate from the documents REST API and its tokens, and from the integrations that let monitoring tools open tickets.

New token asks for:

  • Name: the tool that will use it (“Jira sync”), so you know which token to revoke.
  • Shown as: the name on the notes, replies and changes it makes. Leave it empty to use the token’s name.
  • Acts as: a desk role, Newbie, Agent, Team lead or Manager (never Admin), with its teams. The dialog says what the role can do through the API, such as “Can: Read tickets · update status, priority, team and tags · add notes · reply.”
  • Expires: 30 days, 90 days, 1 year, On a date… (at the end of that day, UTC, within five years) or Never.

The token is shown once. “Copy the token now. It won’t be shown again.” The dialog also gives a command to try it:

curl -H "Authorization: Bearer cdh_…" https://<your-domain>/api/helpdesk/v1/me

A token is cdh_ followed by 43 characters. CompassDocs keeps only a fingerprint of it (its SHA-256) and its last four characters, so the list shows cdh_…a91f. If a token is lost, revoke it and make a new one.

The API tokens list shows each token’s Name, what it Can do, who it Acts as (“Jira sync (Agent, IT Service Desk)” or “(Manager, every team)”), when it was Last used and when it Expires (or Expired, in red). Revoke (“Revoke Jira sync?”) stops it at once: its next call is refused, and what it already wrote stays, under its name. Revoked tokens leave the list, but stay in the audit log.

A workspace can have up to 50 live tokens.

Each token has its own service account: an account that can’t sign in, has no password, belongs to no team, and is never listed among people, assigned tickets, asked to approve or review, or put in a rotation. Its desk role and teams come from the token, and every call is checked by the same rules as the desk, under your roles matrix (a customised role applies). A token can never do more than its role can.

  • The role: only one you could give a person yourself.
  • The teams: only teams you work. A role that sees only its teams’ queues needs at least one (“Choose at least one team: the Agent role only sees its teams’ tickets.”). A role that sees every team’s tickets, such as Manager, can have none, and then works every team.
  • Replies: a token never sends a public reply unless its role can. A Newbie token’s replies are held for a lead’s review, as a newbie’s are.

What a token writes says via API beside the author on the timeline, and a ticket change’s timeline line ends “via API”.

Base: https://<your-domain>/api/helpdesk/v1. Send the token as Authorization: Bearer <token>. Bodies are JSON in and out; an error is { "error": "<sentence>" }. Session cookies are never read on these routes, so a browser that is signed in can’t call them.

Method and route What it does
GET /me The token (id, name, expires_at), who it acts as (name, role, role_label, teams), the can line and its capabilities.
GET /tickets Lists and searches tickets the token works.
GET /tickets/{number} One ticket with its messages.
PATCH /tickets/{number} Changes status, priority, team_id, assignee_id or tags.
POST /tickets/{number}/notes Adds an internal note: { "body": "…" }.
POST /tickets/{number}/replies Adds a public reply: { "body": "…", "status": "solved" } (status optional).
GET /approvals Pending approvals on requests in the token’s queues.
GET /reports/summary Headline report numbers.
Parameter Values
view open (the default), all, mine, unassigned, overdue, waiting, workflow, solved_week or team:<id>. A q with no view searches every status.
status A status, such as open or solved. With no view, it searches all.
priority low, normal, high or urgent.
team A team id.
assignee A user id, or none.
tag A tag.
q Text to search for.
created_from, created_to YYYY-MM-DD, in the helpdesk’s time zone.
sort updated (the default), created or number.
limit 1 to 100 (25 by default).
cursor The next_cursor from the previous page.

It answers { "tickets": [...], "total": 123, "next_cursor": "…" }. Each ticket has its number, subject, status, priority, team, assignee, requester (name), tags, category, source, in_workflow, due, created_at, updated_at and url in the desk.

Paging: pass next_cursor back with the same filters for the next page; it is null on the last one. A cursor belongs to its filters (“That cursor doesn’t belong to these filters. Start again without one.”). It counts by position, so a ticket that changes while you page may show twice or move to another page.

The ticket’s fields as above, plus the requester’s email, the request type, the reply and resolution targets (first_reply_due, resolve_due), first_replied_at, solved_at, closed_at and merged when it was merged, then messages: each with its id, kind, author (name), via, body, attachments (names) and created_at.

Public replies and status lines are always included. Internal notes, and replies held for review (with their review_state), are included only if the token’s role can add internal notes in the ticket’s team; internal_notes_included says which.

Send only what changes. Each change needs the desk capability for it, as in the desk: Change status, Change priority, Transfer to another team, and Assign within their teams or Assign to anyone. Tags need only that the token works the ticket. Any other field is refused (“Only status, priority, team_id, assignee_id, tags can be changed here (not subject).”). It answers { "ticket": …, "changed": [...] }.

Solving a workflow task with required hand-off fields left empty, or a ticket whose required checklist items aren’t ticked, is refused with 409, as in the desk.

  • Notes need Add internal notes. Answers 201 { "message": … }.
  • Replies need Reply to the requester or Reply after review; a status in the same body also needs Change status. Answers 201 { "message": …, "held": false, "status": "solved" }. With Reply after review, held is true and nothing is sent until a lead approves it.

{ "approvals": [...] }: each pending approval on a request in the token’s queues, with its id, step, ticket (number, subject, url), request_type, requester (name), waiting_since and the request’s answers. Approvals can only be listed; deciding them is done in the app.

The same headline numbers as the Claude connector’s hd_report, for from and to (YYYY-MM-DD) and optionally one team: created, solved, median first reply and resolution in business minutes, targets met, satisfaction, and the split by team, agent and category. Every team needs All reports; one team needs Team reports in that team. It is limited to 30 a minute per token.

Code When
401 The token is missing, malformed, unknown, revoked or expired. One answer for all: “Invalid or missing API token. Send Authorization: Bearer .”
402 The helpdesk isn’t licensed.
404 The ticket doesn’t exist, isn’t one the token works, or is in the recycle bin. The answer is the same (“No ticket #1047.”), so ticket numbers reveal nothing.
403 The token works the ticket, but its role may not make that change.
409 The ticket was merged, a workflow task’s hand-off is missing, or required checklist items aren’t ticked.
400 A filter, field or body that isn’t valid. The sentence says which.
413 The body is too large.
429 Over a limit (see below), with Retry-After.
  • 120 calls a minute per token.
  • 30 failed token checks a minute from one address. Only failed checks count, and a valid token is never refused because of them, so a stale script on a shared network can’t lock out a working one.
  • Notes and replies also follow the desk’s own message limits, and report numbers 30 a minute per token.

Every call is recorded once, with the token’s name, the route, the status and the outcome. Changes, notes and replies are recorded under their own actions (helpdesk.ticket.updated, helpdesk.note.added, helpdesk.reply.added, with via: "api"); anything else is helpdesk.api.call. To keep a script that polls a queue from filling the log, successful reads are recorded at most once a minute per token and route. Refused calls are always recorded; failed token checks at most once a minute per address.

New webhook asks for:

  • Endpoint: an https address. Deliveries are POSTed as JSON and signed. It can’t hold a user name and password (“Leave the user name and password out of the address: deliveries are signed instead.”) or a #fragment. An address on a private network is refused unless the server sets COMPASSDOCS_FETCH_ALLOW_PRIVATE=1 (see A receiver on a private network).
  • Name (optional).
  • Events: tick the ones the receiver wants (below). A new webhook starts with ticket.created, ticket.updated and ticket.solved.
  • Teams: it hears about the tickets of these teams only (see Which tickets a webhook hears about).
  • Include internal notes: “Send note.created events with the note’s text. Off: notes never leave the desk.”

Add webhook shows the signing secret once (“whsec_…”), with how to check a delivery. Afterwards the list shows only whsec_…9f2c.

A workspace can have up to 25 webhooks.

Event Sent when
ticket.created A new ticket or request. It is sent first, before any change a rule makes to the new ticket.
ticket.updated Status, priority, team, owner, tags or subject changed. changes lists which.
ticket.solved A ticket is solved or closed from an open status.
message.created A public reply from the desk or the requester. A newbie’s reply held for review is sent only once a lead approves it.
note.created An internal note. Only to webhooks that include internal notes.
approval.decided An approval step is approved or declined.
ticket.deleted A ticket is moved to the recycle bin, by an agent or a retention rule.
ticket.restored A ticket is restored from the recycle bin.

A merge sends ticket.updated (changes ["status"]) for each merged ticket, plus ticket.solved if it was still open, and ticket.updated (["tags"]) for the kept ticket when its tags changed. Closing solved tickets automatically sends ticket.updated (["status"]) for each. Sharing and printing a ticket send nothing, and neither does deleting a binned ticket for good: the receiver already had ticket.deleted.

A webhook hears about the tickets of the teams it is filtered to: what an agent of those teams would see on the ticket, and no more. With no team ticked it hears about every team, tickets with no team included. A ticket moved out of a webhook’s team sends that webhook the ticket.updated about the move.

Whoever sets up a webhook must work those teams themselves:

  • every team ticked must be one of yours (“Choose teams you work in.”);
  • leaving every team unticked needs you to work every team (“Choose at least one team: you don’t work every team, so a webhook of yours can’t hear about all of them.”);
  • Include internal notes needs Add internal notes in each of those teams.

On a webhook that reaches further than you do, you can’t change the address, the teams or the notes setting (“This webhook reaches teams you don’t work, so you can’t change where it sends or what it includes. You can still turn it off or delete it.”). You can still test it, turn it off and delete it.

A ticket in the recycle bin sends nothing except the ticket.deleted that put it there, and a delivery waiting for a retry isn’t sent once its ticket is in the bin or deleted for good.

Each delivery is a POST with Content-Type: application/json and these headers:

  • X-CompassDocs-Signature: t=<unix seconds>,v1=<signature> (see Checking a delivery);
  • X-CompassDocs-Event: the event’s name;
  • X-CompassDocs-Delivery: the event’s id, the same on every retry, so a receiver can drop duplicates.

The body:

{
"id": "evt_…",
"event": "ticket.updated",
"created_at": "2026-11-07T14:02:11.123456+00:00",
"actor": { "name": "Ana Kowalski" },
"ticket": {
"number": 1047,
"subject": "Laptop won't connect to VPN",
"status": "open",
"priority": "high",
"team": { "id": 2, "name": "IT Service Desk" },
"assignee": { "id": 14, "name": "Ana Kowalski" },
"requester": { "name": "Daniel Ortiz", "email": "daniel@example.com" },
"tags": ["vpn"],
"category": null,
"request_type": "Laptop or device problem",
"source": "portal",
"created_at": "…",
"updated_at": "…",
"solved_at": null,
"url": "https://<your-domain>/desk/tickets/1047"
},
"changes": ["priority"]
}

Depending on the event it also carries:

  • changes (ticket.updated);
  • message, with id, kind, from (requester or desk), author (name), body, attachments (names) and created_at (message.created, note.created);
  • approval, with id, step, decision, decided_by (name), decided_at and note (approval.decided).

When an API token made the change, actor also has "via": "api" and api_token (id and name). created_at comes from the database clock.

The signature is the hex HMAC-SHA256 of the time, a dot, and the raw body ("<t>.<body>"), made with the webhook’s secret. It is made fresh for each attempt. Check it against the raw body, before parsing it, and refuse a t more than five minutes old:

import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const want = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return typeof parts.v1 === "string" && want.length === parts.v1.length &&
timingSafeEqual(Buffer.from(want), Buffer.from(parts.v1));
}

Answer with any 2xx once you have the delivery. Anything else, a redirect included, counts as a failed attempt.

  • A delivery is queued once the change is saved, and sent straight away.
  • Each attempt has 10 seconds. A 2xx is delivered. A redirect (3xx) is never followed, so a POST is never re-sent somewhere else: “The endpoint answered with a redirect (HTTP 301). Redirects aren’t followed: use the final address.”
  • A failed attempt is retried after 1, 5, 15 and 30 minutes, then 1, 2 and 4 hours, then every 6 hours, until 24 hours after the event. One last attempt is always made at the 24-hour mark; if it fails, the delivery fails (“Gave up after 24 hours.”).
  • One delivery is in flight per webhook at a time, the oldest due first, so the receiver gets a webhook’s events in order. A retry can still be overtaken by newer events that come due first.

When a webhook has been failing for 24 hours, it turns itself off: its chip says Off (“Turned off after failing for 24 hours.”), deliveries waiting for a retry are dropped, and everyone who holds Helpdesk settings gets a bell, Webhook turned off: “It failed for 24 hours. Check the endpoint, then turn it back on in Desk settings, API and webhooks.” Any delivery that succeeds clears the failing count.

Turn on starts it again with a clean slate.

Each row shows the name and endpoint, the events, the teams (or All), and Last delivery as a chip: “200 · 2m ago”, Failing · retrying, Failing, Off or No deliveries yet.

  • Edit and Send a test are on the row. A test sends one signed ping now, with the same envelope as real events, and shows the endpoint’s answer. It is tried once and never counts against the webhook.
  • The … menu holds:
    • Delivery log: each delivery’s time, event (and ticket number), outcome (Delivered, Queued, Retrying with the attempts and the next try, Sending, Failed), the response code and the answer: the error, then the first 2 KB of the response. The newest 50 finished deliveries are kept per webhook. The payload is never shown.
    • Rotate secret…: a new secret signs every delivery from then on, and the old one stops at once. It is shown once; update the receiver straight away.
    • Turn off… / Turn on: turning off drops deliveries waiting for a retry.
    • Delete…: it stops at once and its delivery log goes with it.

Audit entries and bells name only the endpoint’s host, never its path, since a path can hold the receiver’s own secret.

Deliveries go through the same address checks as other outbound connections: by default an address on a private network (such as 10.x, 172.16.x to 172.31.x and 192.168.x) is refused, at save and at send (“That address is on a private or local network, which webhooks can’t reach unless the server sets COMPASSDOCS_FETCH_ALLOW_PRIVATE=1.”). To reach one, set COMPASSDOCS_FETCH_ALLOW_PRIVATE=1 on the server (see Environment variables). Loopback addresses are always refused. When HTTPS_PROXY is set, deliveries go through the proxy.

A delivery’s payload is kept only until it is delivered or fails, then removed; the log keeps the outcome. Anonymising a ticket drops its deliveries still waiting, and removing an account drops the waiting deliveries that person caused or that hold their words.

Action Shown as
helpdesk.api_token.created / .revoked Created / Revoked a helpdesk API token (with its role, teams, expiry and last four characters)
helpdesk.api.call Called the helpdesk API (route, status and outcome)
helpdesk.reply.added Replied to a requester (a token’s public reply)
helpdesk.webhook.created / .updated / .deleted Added / Edited / Deleted a helpdesk webhook
helpdesk.webhook.secret_rotated Rotated a helpdesk webhook’s signing secret
helpdesk.webhook.tested Sent a test to a helpdesk webhook (with the outcome)
helpdesk.webhook.auto_disabled Turned off a failing helpdesk webhook

A token’s changes and notes are helpdesk.ticket.updated and helpdesk.note.added with via: "api". No entry ever holds a token or a secret.

Symptom or message What to check
401 “Invalid or missing API token. …” The header is Authorization: Bearer <token>, and the token hasn’t been revoked or expired.
404 for a ticket you can see in the desk The token’s teams don’t include its team, or it is in the recycle bin. Check Acts as.
403 “You don’t have permission for that.” The token’s role lacks the capability for that change.
429 “Too many failed token checks. Wait a minute.” A script on that address keeps sending a bad token. Fix it; valid tokens still work.
“You can’t give a token that role.” You can only give roles you could give a person.
“Choose teams you work in.” Pick only your own teams.
A webhook shows Failing · retrying Open the Delivery log for the code and answer. “No answer within 10 seconds.” means the receiver is slow: answer first, then do the work.
“The endpoint’s host name could not be found.” Check the address, and the server’s DNS.
“The endpoint could not be reached (connection refused, reset, or a certificate problem).” Check the receiver is up, and its certificate.
“Not sent: the ticket is in the recycle bin.” The ticket was deleted while the delivery waited.
“The signing secret can’t be read (the server’s secret key changed). Rotate the secret.” COMPASSDOCS_SECRET_KEY changed. Rotate secret… and update the receiver.