More in Setting up a platform

Connect your ticket system

Connect HaloPSA, ConnectWise, Autotask, Zendesk, Freshdesk, Jira Service Management, ServiceNow, Zoho Desk, Jira, Linear or YouTrack, or your own ticket system through our API and a signed webhook. Then set the ticket rules.

In the appPlatformSettingsTicket system

Ticket system sets where tickets come from, and where approved replies go back to the requester. Everything is per platform. Each platform connects one ticket system at a time, with its own API keys and addresses.

The label at the top of the page says how it stands: HaloPSA, Your own system (an address for approved replies is set), or Not connected.

The page has two tabs:

  • Connection: which ticket system the platform uses, and its settings.
  • Ticket rules: what you call your ticket system, which account a ticket is about, and closing quiet tickets. These apply to every ticket, however it arrived.

Connect a ticket system#

With no ticket system yet, the Connection tab shows Connect your ticket system and one button.

  1. 1

    Click Connect a ticket system.

  2. 2

    In the box that opens, Where do your tickets come from?, click the system your team keeps support tickets in.

  3. 3

    Its settings show on the page. Fill them in to connect it. Choose another at the top takes you back to the list until it is connected.

SystemWhat it means
HaloPSAA service desk for IT teams. Connect with a key from Halo. No code. See HaloPSA below.
ConnectWise PSAA service desk for IT companies. Connect with an API member's keys. See ConnectWise PSA.
Autotask PSAA service desk for IT companies. Connect with an API user. See Autotask PSA.
ZendeskA help desk. Connect with an OAuth client from Zendesk. No code. See Zendesk.
FreshdeskA help desk. Connect with an agent's API key. No code. See Freshdesk.
Jira Service ManagementAtlassian's service desk. Connect with an API token. No code. See Jira Service Management.
ServiceNowA service desk for larger organisations. Connect with an integration user or OAuth. See ServiceNow.
Zoho DeskA help desk. Connect with a Zoho Self Client. No code. See Zoho Desk.
JiraYour team's own Jira projects. Replies go on the issue as comments. See Jira.
LinearYour team's issue tracker. Replies go on the issue as comments. See Linear.
YouTrackYour team's issue tracker. Replies go on the issue as comments. See YouTrack.
Your own systemYour ticket system sends tickets to our API and takes replies from a webhook (a web address in your system that we call). This needs a developer. See Your own system below.

In the box, service desks and help desks come first, then issue trackers. A service desk has a requester to reply to. An issue tracker is your team's own list of work: there, replies and the agent's findings are comments on the issue, which everyone on it can see.

Once connected, the top of the tab shows its name and Connected, and only its settings show.

One ticket system per platform

Tickets come from one place, and replies go back to the same place. To use another system, click Disconnect… at the top of the Connection tab first, then connect the new one. Tickets already here stay, but replies on tickets from the old system no longer go anywhere. A company with two ticket systems, say one for customers and one for developers, uses two platforms.

HaloPSA#

If your tickets live in HaloPSA, connect it directly. Halo tells us about new tickets, we read each one from Halo with your key, and approved replies go back into Halo.

  1. 1

    In Halo, make a key. Go to Configuration › Integrations › Halo API, then View Applications, and press New. Choose Client ID and Secret (Services) as the way it signs in, and an agent for it to act as. Replies and notes show as written by that agent: use an agent of its own, such as Support Agent, so they don't look as if you wrote them. Under Permissions, tick all, then save. Show me how on our page shows these steps too.

  2. 2

    Here, go to Settings › Ticket system. On the Connection tab, click Connect a ticket system and choose HaloPSA.

  3. 3

    Under Your Halo, fill in the Halo address, the Tenant (hosted Halo only), the Client ID and the Client secret.

  4. 4

    Under Which clients, tick the Halo clients whose tickets this platform takes, or leave them all unticked to take every client's. Choose how Approved replies go back, what happens to a Halo ticket When a ticket comes in and After a reply, and whether to add the Agent's findings in Halo.

  5. 5

    Click Connect Halo. Then click Check connection.

  6. 6

    In Halo, go to Configuration › Integrations › Webhooks › New. Paste the Webhook address from Let Halo tell us about new tickets on our page as the Payload URL. For authentication, choose Basic Authentication, and paste the User name and Password from our page. Add the events New Ticket Logged, Ticket Updated by User and Closed. Save.

  7. 7

    Try it. Log a test ticket for one of your chosen clients. It shows under Tickets here within a minute, and our page says Last heard from Halo with the time.

SettingWhat it does
Halo addressThe address you open Halo at, for example https://yourcompany.halopsa.com.
TenantHosted Halo only. It is shown in Halo under Configuration › Integrations › Halo API. Leave it empty if Halo runs on your own server.
Client ID and Client secretThe key you made in Halo. Once saved, the secret is never shown again. Type a new one to replace it.
Check connectionShows once Halo is connected. It asks Halo for a ticket with your key, and says whether that worked.
Webhook addressShows once Halo is connected. Paste it into Halo, with Copy. Keep it private. To send only some tickets, add conditions to those events in Halo.
User name and PasswordShow once Halo is connected, to people who may change settings. Halo can't sign its calls, so it signs in to us instead: in Halo's webhook, choose Basic Authentication and paste both. We refuse calls without them. Make a new secret makes another password: the old one stops at once, so paste the new one into Halo straight away. A connection made before passwords has none: our page warns about it, with Make a secret.
Last heard from HaloWhen Halo last called the webhook address. Never yet means it hasn't called: check the steps. If a call was refused, it says when and why, such as wrong password.
Which clientsShows once Halo is connected: your Halo clients, read from Halo. Tick the ones whose tickets come to this platform, the clients whose systems this platform can look into. With none ticked, every client's tickets come here. A client that another of your platforms takes from the same Halo can't be ticked, and says which platform takes it. Saved with Save. Tickets already here stay here.
Approved repliesHalo emails it to the requester: straight away, as a reply from the agent you chose in Halo. Add it as a private note: your team reads it in Halo and sends it themselves.
Halo action namesUnder Approved replies, a line names the Halo actions we use, Email User and Internal Note at first. Click Change only if your Halo uses other names (Configuration › Tickets › Actions).
When a ticket comes inAssign it to: a Halo agent for the AI, such as one you make in Halo called "Cerberius Agent", so Halo shows the ticket is being worked on. Set its status to: for example In Progress. Only tickets nobody has taken yet, still New, are changed. Both are off at first. Add that agent, in Halo, to the teams your tickets go to: Halo won't give a ticket to an agent outside its team, and keeps it unassigned. The ticket's story then says so.
After a replyAssign the ticket to whoever approved the reply: the Halo ticket goes to that person's Halo agent, found by their email address, or by their exact name if they use a different address in Halo. Nothing changes if there is no match, or if two Halo agents have that name. Then set its status to: a status from your Halo's own list, such as one your team uses for "waiting on the customer", or Leave it as it is. Both are off at first. If Halo refuses either change, the reply still counts as sent.
Agent's findings in HaloWhen on, a private note with what the agent found goes on the Halo ticket as soon as a reply is ready to approve. Only your Halo team sees it. Off at first.

Once Halo is connected, change anything here and click Save at the bottom.

Tickets opened in Halo before you connected don't come in by themselves. Click Import tickets at the top of the page to bring them in, with their conversation. See Import tickets from Halo.

What happens with a Halo ticket:

  • A new ticket in Halo reaches the agent, with its files.
  • A reply from the requester in Halo reaches the agent as a follow-up. Your team's replies and private notes are skipped.
  • Closing it in Halo stops the work on it here.
  • Closing it here closes it in Halo too.

One Halo, a platform per client

An IT company often runs one Halo for all its clients, and each client's systems are their own platform. Connect each client's platform to the same Halo, and under Which clients tick that client. Each platform has its own Webhook address: add each one to Halo as its own webhook. Two platforms never take the same client's tickets.

Only tickets that came from Halo go back to Halo. Other tickets on the same platform, from the log watcher for example, don't.

To stop, click Disconnect… at the top of the Connection tab, and confirm. New tickets from Halo stop coming in, and replies stop going back to it. The webhook address stops working at once.

Your own system#

This part is for the developer who connects your own ticket system to Cerberius Agents.

There are two directions:

  1. Your ticket system → Cerberius Agents. Your system sends each new ticket, each follow-up from the requester, and each close.
  2. Cerberius Agents → your ticket system. A webhook receives each approved reply. Your system posts it to the requester.
  1. 1

    Create an API key and store it in your ticket system.

  2. 2

    Send new tickets to the tickets address with that key.

  3. 3

    Set the address for approved replies, and store the signing secret it shows you.

  4. 4

    Check the signature on every reply you receive, then post the reply to the requester.

On the Connection tab, click Connect a ticket system and choose Your own system. The settings show as rows: API keys, Send tickets here, Approved replies and Status updates. It counts as connected once Approved replies has an address.

Send tickets here shows the exact address to send tickets to, with Copy. In the examples below, replace https://YOUR-ADDRESS with the start of that address.

1. Create an API key#

  1. 1

    Go to Settings › Ticket system. On the Connection tab, click Connect a ticket system and choose Your own system. Once it is connected, its rows show straight away.

  2. 2

    In the API keys row, click Create API key.

  3. 3

    In the box that opens, enter a Name and choose Allowed to.

  4. 4

    Click Create API key.

  5. 5

    Copy the key now, then click Done. It is shown once, and never again.

FieldWhat it does
NameFor you, for example the system that uses it. It starts as Ticket system.
Allowed toTicket system sends tickets and reads their status. Ticket system with its own approval screen can also read drafts, approve or reject them, and talk to the agent.

Keys start with sk_. The table shows each key's name with its first characters and the day it was made, what it is Allowed to do (Send and read tickets or Tickets and approvals), and when it was Last used (Never until it is).

To stop a key at once, click ⋯ on its row, then Revoke…, and confirm. Systems using it stop working straight away. A revoked key stays in the table, greyed out, marked Revoked.

Keep the key on the server

The key can create tickets on your platform. Store it in your ticket system's server configuration, never in a browser or a mobile app.

2. Send a new ticket#

curl -X POST https://YOUR-ADDRESS/v1/tickets \
  -H "Authorization: Bearer sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "T-1234",
    "subject": "I can'\''t download my invoice",
    "body": "When I click Download invoice nothing happens.",
    "requester": {
      "external_id": "user-77",
      "email": "[email protected]",
      "name": "Sam",
      "organization": "Riverside Store"
    },
    "metadata": { "account_id": "acc_19", "plan": "pro" },
    "attachments": [
      {
        "url": "https://files.example.com/signed/abc123",
        "filename": "screenshot.png",
        "content_type": "image/png"
      }
    ]
  }'
FieldRequiredWhat it is
external_idYesYour ticket's ID. Up to 200 characters.
subjectYesUp to 500 characters.
bodyYesThe requester's message. Up to 100,000 characters.
requesterNoWho opened the ticket: external_id, email, name, organization. Take these from the signed-in user in your system, not from the ticket text. The agent uses them to find the right log lines.
metadataNoAny details that help, such as account ID, plan or app version. The agent treats this as the requester's account and keeps its answer to that account.
attachmentsNoUp to 20 files: url, filename, and optionally content_type.
created_atNoWhen the ticket was opened in your system, as an ISO 8601 date and time.

The answer is 201 Created with the ticket, including our ID for it (tkt_…) and its status. The investigation starts at once.

We also give each ticket our own number: #1, #2 and so on, counted separately on each platform. Your external_id stays your number. Both show on the ticket and in our lists, for example #12 · T-1234, and our search finds either.

Sending the same ticket twice is safe#

Tickets are matched on external_id. If you send the same external_id again, you get the existing ticket back with 200 OK, and nothing is investigated twice. So retry freely on timeouts and errors.

Attachments#

We download each file straight away, within seconds, and keep it. Short-lived signed links are fine. We accept PNG, JPEG, GIF and WebP images, PDF, plain text and CSV, up to 15 MB each. The investigation waits up to two minutes for the files. Approvers can open them on the ticket page.

Following progress#

GET /v1/tickets/{id} (with our tkt_… ID) shows the ticket's current status:

StatusMeaning
newReceived, waiting to start.
not_startedWaits for a person to start the agent (imported without starting, or held back by the log watcher).
investigatingThe agent is working on it.
awaiting_approvalA reply is waiting for a person.
fixingA fix was approved and is being made.
repliedThe reply was delivered to your ticket system.
failedThe investigation failed. Someone can start it again.
needs_personAn approver rejected the reply. Someone will answer the requester directly.
closedClosed.

3. When the requester writes again#

curl -X POST https://YOUR-ADDRESS/v1/tickets/T-1234/messages \
  -H "Authorization: Bearer sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{ "external_id": "M-9", "body": "It also happens for order B-78.", "attachments": [] }'

Use your ticket's external_id in the address, and your message's own ID as external_id in the body. Any reply waiting for approval is withdrawn, and the agent continues with the new message. A message on a closed ticket opens it again. Sending the same message external_id twice is safe.

4. When a ticket is closed on your side#

curl -X POST https://YOUR-ADDRESS/v1/tickets/T-1234/close \
  -H "Authorization: Bearer sk_your_key"

Any work still queued stops, and replies waiting for approval are withdrawn.

5. Receive approved replies#

  1. 1

    On Settings › Ticket system, Connection tab, with Your own system chosen, find Approved replies.

  2. 2

    Click Set address….

  3. 3

    In the box that opens, enter the Address of your endpoint, and click Save.

  4. 4

    Copy the signing secret now, then click Done. It starts with whsec_ and is shown once.

  5. 5

    Store it in your ticket system's configuration.

FieldWhat it does
AddressYour endpoint. It must accept POST requests with a JSON body. It must be an http or https address with no user name or password in it.

When someone approves a reply, we send:

POST https://tickets.example.com/support-agent/reply
content-type: application/json
webhook-id: msg_4f1c…
webhook-timestamp: 1759201234
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{
  "type": "reply.send",
  "ticket_external_id": "T-1234",
  "draft_id": "drf_…",
  "reply_text": "Thanks for reporting this. …",
  "approved_by": { "email": "[email protected]", "name": null },
  "auto": false
}

auto is true for an automatic reply.

Your endpoint must:

  1. Check the signature (below). Reject anything that fails.
  2. Ignore duplicates. webhook-id stays the same on every retry of one delivery. If you already posted a reply for that webhook-id, return 200 without posting it again.
  3. Post reply_text to the requester on ticket ticket_external_id.
  4. Return a 2xx status within 10 seconds.

Closing a ticket from Cerberius Agents#

When an approver closes a ticket on our side, the same URL receives a second kind of message, signed the same way:

{
  "type": "ticket.close",
  "ticket_external_id": "T-1234",
  "closed_by": { "email": "[email protected]", "name": null },
  "after_delivery_id": "msg_…"
}

Close the ticket as your own Close button would, and return 2xx. Closing twice must be harmless. Don't call our close address back: the ticket is already closed on our side.

If the approver also sent a closing message, it arrives first as a normal reply.send. after_delivery_id is its webhook-id. The close is only sent once that reply was accepted.

Ignore any other type you don't know, and return 2xx.

Retries#

Anything other than a 2xx answer, a timeout or a connection error is tried again after about 10 seconds, 30 seconds, 1, 5, 15 and 30 minutes, then 1, 2, 4, 8 and 8 hours. That is about 24 hours in total. After that the reply shows as Not delivered, and an approver can click Try again on the reply page.

An address we're not allowed to reach fails at once, without retries.

Change the address or the secret#

Once an address is set, Approved replies shows it, with two ways to change things:

  • Change… opens the address in a box. Saving it also makes a new signing secret, shown once.
  • New signing secret… replaces the secret and keeps the address. Confirm with Create new secret.

Either way, the old secret stops working at once. Update your ticket system straight away, or it will reject replies.

To stop, click Disconnect… at the top of the Connection tab, and confirm. The address for approved replies is removed, and replies stop going to your system. Your API keys stay until you revoke them.

Check the signature#

We follow the Standard Webhooks specification, so their ready-made libraries work. By hand:

key       = base64-decode( the secret without "whsec_" )
signed    = webhook-id + "." + webhook-timestamp + "." + raw request body
expected  = "v1," + base64( HMAC-SHA256( key, signed ) )
valid     = expected equals one of the space-separated values in webhook-signature
            AND webhook-timestamp is within 5 minutes of now

Use the raw body bytes, before any JSON parsing.

Python#

import base64, hashlib, hmac, time

def verify(secret: str, headers: dict, body: bytes) -> bool:
    msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"]
    if abs(time.time() - int(ts)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    digest = hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()
    expected = "v1," + base64.b64encode(digest).decode()
    return any(hmac.compare_digest(expected, s) for s in headers["webhook-signature"].split())

Node.js#

import crypto from "node:crypto";

// headers: lower-case header names. rawBody: a Buffer of the request body, before parsing.
export function verify(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected =
    "v1," +
    crypto.createHmac("sha256", key).update(`${id}.${ts}.`).update(rawBody).digest("base64");
  return (headers["webhook-signature"] || "").split(" ").some(
    (sig) =>
      sig.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
  );
}

With Express, read the raw body with express.raw({ type: "application/json" }) on that route, so req.body is a Buffer.

Status updates (optional)#

A second address, Status updates, receives status updates for your own tools, signed the same way. Set it up the same way as Approved replies. It has its own secret.

{
  "type": "ticket.event",
  "event": "investigating",
  "ticket_external_id": "T-1234",
  "ticket_id": "tkt_…",
  "draft_id": null,
  "detail": null,
  "at": "2026-10-05T03:14:15+00:00"
}

event is one of: investigating, draft_ready, needs_info, agent_replied, run_failed, reply_sent, fix_pr_opened, change_requested, change_done, change_failed, close_suggested, closed. Use them to show "the agent is looking at this" in your tools. Missing one is harmless: GET /v1/tickets/{id} always has the current state.

What you call it#

Tickets name the ticket system they came from. For example, a ticket's story says Tom Barker wrote in Alion helpdesk, and Sent to Alion helpdesk the reply to Tom. You write to the requester To Tom, through Alion helpdesk. Give your own system the name your team knows it by.

  1. 1

    On Settings › Ticket system, open the Ticket rules tab.

  2. 2

    Under What you call it, enter the Name, for example Alion helpdesk.

  3. 3

    Click Save at the bottom.

FieldWhat it does
NameYour own ticket system's name, as tickets show it. Up to 100 characters. Leave it empty to say "your ticket system".

With HaloPSA connected, tickets say Halo, whatever the name here. Only owners and admins can change it.

Account field#

Most tickets are about one account in your product. The Account field tells the agent which ticket detail identifies it. The agent then only looks at, and only talks about, that account, even if the requester names another one.

  1. 1

    On Settings › Ticket system, open the Ticket rules tab.

  2. 2

    Under Account field, enter the Field name: the key your ticket system sends in metadata, for example account_id.

  3. 3

    Click Save at the bottom.

FieldWhat it does
Field nameThe metadata key that holds the requester's account. Letters, digits, _, - and . only, up to 100 characters. Leave it empty if tickets aren't about one account.

Close quiet tickets#

Tickets often stay open after you answer, because the requester never writes back. The service can close them for you.

  1. 1

    On Settings › Ticket system, open the Ticket rules tab.

  2. 2

    Under Close quiet tickets, switch on Close quiet tickets automatically.

  3. 3

    Choose the Days without a reply, and check the Closing message.

  4. 4

    Click Save at the bottom.

FieldWhat it does
Days without a replyHow long after our last reply, with nothing from the requester, the ticket closes. From 1 to 365.
Closing messageSent to the requester as the ticket closes. {name} becomes their first name and {days} the number of days. Leave it empty to close without a message.

The days and the message can only be changed while the switch is on. Save saves What you call it, the Account field and Close quiet tickets together.

Only tickets that were answered and wait on the requester close this way. A ticket with a reply waiting for approval, the agent at work, or a person handling it stays open. So does any ticket with Keep open switched on, on its page. Tickets are checked every hour, up to 20 per check. If some tickets are already quiet for longer than the days you chose, the tab says how many will close within the hour.

If the requester writes again after the close, the ticket opens again and the agent looks at their message.

Your own approval screen (optional)#

With an API key that is allowed to use its own approval screen, your tools can do what our web app does:

CallWhat it does
GET /v1/drafts?status=pendingReplies waiting for approval, oldest first.
GET /v1/drafts/{id}The ticket, the agent's analysis and the draft reply. The ETag header is the draft's version.
POST /v1/drafts/{id}/approveSend the reply, optionally edited: {"reply_text": "…", "actor": {"email": "…"}}.
POST /v1/drafts/{id}/rejectSend nothing: {"reason": "…", "actor": {"email": "…"}}.
POST /v1/drafts/{id}/retry-deliveryTry again after Not delivered.

Approve, reject and retry need an If-Match: <version> header with the version you showed. If someone changed the draft meanwhile, you get 409 with the current draft, instead of a double action. Include actor: the person at your company who clicked the button. It is recorded on the ticket, in its story.

Platforms without a ticket system#

A platform doesn't need a ticket system. For example, a platform may only get tickets from the log watcher.

Without HaloPSA or an address for approved replies, there is no one to send a reply to. Approving a reply then only records that you agree with it and marks the ticket done. The reply page says so before you approve. Automatic replies are never sent.