Automating Support Ticket Triage in Your Helpdesk Pipeline
Build a support ticket triage pipeline: webhook intake, category and priority classification, human-review routing, and safe fallbacks. Code in curl, TypeScript and Python.
Triage is the least glamorous part of support, and also the place where most response-time problems start. A ticket that says "can't log in since this morning, whole team is blocked" sits in the general queue behind fourteen feature requests because nobody tagged it. Meanwhile a senior engineer spends the first hour of every day reading tickets just to decide where they go.
This guide covers how to automate that first routing decision without making things worse. The design applies to Zendesk, Freshdesk, Help Scout, Intercom, Front, or a homegrown inbox. For the classification step we use the Kovca Support Triage API, but the parts most teams get wrong are the parts around the classifier.
What "triage" should actually decide
Keep the automated decision small. A triage step should answer three questions:
- Which queue? Billing, technical, account, product, or other.
- How urgent? Is the customer blocked right now, or can they keep working?
- Does a human need to look before automation acts? Legal threats, security reports, and ambiguous messages should never be auto-handled.
Everything else (auto-replies, SLA timers, assignment to a person) is your business logic, and it should key off those three answers. Mixing "classify" and "act" in one opaque step is how teams end up auto-closing a GDPR request.
Rules first, then a classifier
Before adding any ML, write down the deterministic rules you already know:
- Tickets from your status-page integration go straight to
technical/high. - Anything from a known enterprise domain gets a priority floor.
- Messages matching your own internal keywords (a product codename, say) go to a specific team.
Run these first. They're cheap, explainable, and they handle your most predictable traffic. The classifier handles the long tail of free-text messages that rules can't.
The pipeline
- 1Helpdesk webhookverify the signature, enqueue the ticket ID, return 200
- 2Intake workerstrip quoted replies, signatures, auto-responder text
- 3Your rulesstatus-page alerts, enterprise domains, codenames
- 4Kovca Support Triagecategory, priority, requires_human_reviewReview queuerequires_human_review: true
- 5Routingyour queues, your priority floors
- 6Helpdesk APItags, custom fields, request_id
1Intake from a webhook
Most helpdesks can fire a webhook on ticket creation. Do almost nothing in the webhook handler: verify the signature, put the ticket ID on a queue, and return 200. Classification happens in a worker so slow calls or retries never make the helpdesk time out and re-send.
2Classify the message
The Support Triage endpoint takes a message (1 to 8,000 characters) and an optional subject (up to 200). It returns a category, a priority, and a review flag:
{
"request_id": "req_8sJq2LwzVd0cYb1E",
"category": "billing",
"priority": "medium",
"requires_human_review": false
}The categories are fixed: billing, technical, account, product, other. Priority is low (a question or feedback), medium (a problem, but the customer can keep working), or high (blocked right now). The rules behind it are published, which matters when you have to explain routing to your support lead:
- A likely security problem is always
high. requires_human_reviewistruewhen a legal/regulatory threat or a security problem is reasonably possible, or when the category or urgency judgment itself is unclear.
The underlying likelihoods aren't returned and aren't calibrated confidence scores, so don't build thresholds on top of them. Use the three output fields as routing hints, and let your own rules make the final call.
curl
curl https://kovca.io/api/v1/support/triage \
-H "Authorization: Bearer $KOVCA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"subject": "Refund not received",
"message": "I returned my order two weeks ago and still haven'\''t received my refund."
}'TypeScript (Node 18+)
type Triage = {
request_id: string;
category: "billing" | "technical" | "account" | "product" | "other";
priority: "low" | "medium" | "high";
requires_human_review: boolean;
};
export async function triage(subject: string | undefined, message: string): Promise<Triage | null> {
const body: Record<string, string> = { message: message.trim().slice(0, 8000) };
if (subject) body.subject = subject.slice(0, 200);
for (let attempt = 0; attempt < 4; attempt++) {
const res = await fetch("https://kovca.io/api/v1/support/triage", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.KOVCA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (res.ok) return res.json();
const err = await res.json().catch(() => ({}));
const code = err?.error?.code;
const transient = [500, 503, 504].includes(res.status) || code === "RATE_LIMITED";
if (!transient) break; // INVALID_REQUEST, UNAUTHORIZED, USAGE_LIMIT_EXCEEDED: don't retry
const wait = Number(res.headers.get("Retry-After")) || 2 ** attempt;
await new Promise((r) => setTimeout(r, wait * 1000));
}
return null; // caller falls back to the default queue
}Python
import os, time, requests
def triage(subject, message):
body = {"message": message.strip()[:8000]}
if subject:
body["subject"] = subject[:200]
for attempt in range(4):
r = requests.post(
"https://kovca.io/api/v1/support/triage",
headers={"Authorization": f"Bearer {os.environ['KOVCA_API_KEY']}"},
json=body, timeout=30,
)
if r.ok:
return r.json()
code = r.json().get("error", {}).get("code")
if r.status_code in (500, 503, 504) or code == "RATE_LIMITED":
time.sleep(int(r.headers.get("Retry-After", 2 ** attempt)))
continue
break
return NoneTwo details worth copying. First, send only the customer's words. Strip quoted reply chains, signatures, and your own auto-responder text before calling. A ten-message thread where the customer's latest line is "thanks, all sorted!" shouldn't be classified as high because of a message from last week. Second, never put the key in a browser widget. Call from your backend (authentication docs).
3Route
QUEUES = {"billing": "finance", "technical": "engineering-support",
"account": "account-help", "product": "product-support", "other": "general"}
def route(ticket, result):
if result is None:
return {"queue": "general", "tags": ["triage_unavailable"]} # fail open
tags = [f"cat_{result['category']}", f"pri_{result['priority']}"]
if result["requires_human_review"]:
return {"queue": "triage-review", "tags": tags + ["needs_review"],
"automation": False} # no auto-replies
priority = result["priority"]
if ticket.get("enterprise") and priority == "low":
priority = "medium" # your floor, your rule
return {"queue": QUEUES[result["category"]], "priority": priority,
"tags": tags, "automation": True}The important properties:
- Fail open. If classification is unavailable, the ticket lands in the general queue exactly as it would have without automation. Triage should never be a reason a ticket goes missing.
- Review means hands off. When
requires_human_reviewis true, disable auto-replies and macros for that ticket. That flag exists so a legal threat or a vulnerability report doesn't get "Thanks! Have you tried clearing your cache?" - Your rules override. Customer tier, contract SLAs, and known incidents are things only you know.
4Write back and log
Write the category and priority back as helpdesk tags or custom fields, and store the request_id on the ticket. When an agent re-routes a ticket, log the original and corrected values. After a few weeks that log is your evaluation set. You'll see exactly which kinds of messages get mis-routed, and you can add rules for them.
Rolling it out safely
- Shadow mode, one to two weeks. Classify every ticket and write tags, but don't change routing. Compare against what agents actually did.
- Route, don't respond. Turn on queue assignment only. Keep auto-replies off.
- Expand slowly. Enable automation per category, starting with the one where shadow-mode agreement looked best for your traffic.
Measure first-response time on high tickets and the re-route rate. Those two numbers tell you whether triage is helping.
Volume and cost
One request per new ticket (not per reply) keeps usage predictable. Kovca's Free plan covers 1,000 successful requests a month at 10 per minute, which is plenty for a shadow-mode pilot at many small teams. Pay As You Go is $0.005 per successful request at 60 per minute, with a monthly spending cap (pricing). Invalid or failed requests aren't billed. Message text isn't stored by Kovca; only request metadata is recorded.
Try it on last week's tickets
Pull twenty real tickets, including the ones that got mis-routed, and run them through. Sign up free with no card, create an API key, and paste a ticket into the playground to see the result. Then read the full contract in the Support Triage docs.