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.
API tokens
Section titled “API tokens”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/meA 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.
What a token can do
Section titled “What a token can do”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”.
The REST API
Section titled “The REST 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.
Routes
Section titled “Routes”| 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. |
GET /tickets
Section titled “GET /tickets”| 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.
GET /tickets/{number}
Section titled “GET /tickets/{number}”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.
PATCH /tickets/{number}
Section titled “PATCH /tickets/{number}”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.
POST /tickets/{number}/notes and /replies
Section titled “POST /tickets/{number}/notes and /replies”- Notes need Add internal notes. Answers
201 { "message": … }. - Replies need Reply to the requester or Reply after review;
a
statusin the same body also needs Change status. Answers201 { "message": …, "held": false, "status": "solved" }. With Reply after review,heldistrueand nothing is sent until a lead approves it.
GET /approvals
Section titled “GET /approvals”{ "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.
GET /reports/summary
Section titled “GET /reports/summary”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.
Answers and errors
Section titled “Answers and errors”| 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. |
Rate limits
Section titled “Rate limits”- 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.
In the audit log
Section titled “In the audit log”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.
Webhooks
Section titled “Webhooks”New webhook asks for:
- Endpoint: an
httpsaddress. 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 setsCOMPASSDOCS_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.updatedandticket.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.
Events
Section titled “Events”| 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.
Which tickets a webhook hears about
Section titled “Which tickets a webhook hears about”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.
What a delivery holds
Section titled “What a delivery holds”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, withid,kind,from(requesterordesk),author(name),body,attachments(names) andcreated_at(message.created,note.created);approval, withid,step,decision,decided_by(name),decided_atandnote(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.
Checking a delivery
Section titled “Checking a delivery”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.
Retries and the 24-hour window
Section titled “Retries and the 24-hour window”- A delivery is queued once the change is saved, and sent straight away.
- Each attempt has 10 seconds. A
2xxis 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 keeps failing
Section titled “When a webhook keeps failing”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.
The webhook list
Section titled “The webhook list”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
pingnow, 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.
A receiver on a private network
Section titled “A receiver on a private network”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.
Privacy
Section titled “Privacy”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.
In the audit log
Section titled “In the audit log”| 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.
Troubleshooting
Section titled “Troubleshooting”| 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. |
