Manage queues and routing from code
Keep queues and queue rules in a config file and sync them to Kustomer by key, route conversations from your own integration, and show live wait times and staffing.
This guide treats routing setup as code. Instead of clicking through routing settings in every org, you keep queues and rules in a file, apply it with a script, and use the same API to route your own conversations and watch queue health. By the end you'll have:
- queues upserted by
key, so one config works in a sandbox and production even though IDs differ; - queue rules created, updated and put in order from the same file, including one rule that routes by region;
- an integration that sends a conversation into a queue and copes with routing having picked it up first;
- a wait-time and staffing check you can show customers or alert on.
Diagram source
flowchart LR F["routing.config.ts<br/>queues + rules"] --> S[Sync script] S -- "GET ?key= → POST or PUT" --> Q[(Queues)] S -- "POST/PUT, then PUT order" --> R[(Queue rules)] C[New conversation] --> R -- "first match" --> Q I[Your integration] -- "POST work item" --> Q Q --> A[Available agents] Q -. "metrics, work sessions" .-> D[Wait time and staffing]
You'll need an API key with org.permission.queue.read, .create and .update, and org.permission.queue_rule.read, .create and .update (or the broader org.admin.routing.write) for setup. The integration in step 4 needs org.permission.routing.create, org.permission.routing.read and org.permission.work_item.update. Each reference page lists the exact roles for its operation.
The code uses the kfetch helper from Making requests, which adds the auth header and retries 429s and 5xxs.
How routing picks a queue
When a conversation needs an agent, Kustomer creates a work item for it. If nothing has set a queue yet, routing checks the org's queue rules from top to bottom and uses the first enabled rule that matches. Conversations no rule matches go to the org's default queue (system: true). Available agents on teams that work the queue are then offered items with the lowest priority number first, oldest first within a priority.
Two consequences shape the rest of this guide: rule order matters as much as the rules themselves, and a conversation that already has a queue, for example from a workflow or your integration, skips the rules.
1. Describe routing in a file
Give every queue a stable key. Keys are unique among an org's active queues, and both your script and your rules can find a queue by key without knowing its ID.
export const queues = [
{ key: 'vip', name: 'VIP', displayName: 'VIP Support', priority: 1, itemSize: 1 },
{ key: 'billing', name: 'Billing', priority: 2, itemSize: 1 },
{ key: 'support-us', name: 'Support US', priority: 3, itemSize: 1 },
{ key: 'support-eu', name: 'Support EU', priority: 3, itemSize: 1 },
]
// In routing order: the first enabled match wins, so specific rules go first.
export const rules = [
{
name: 'VIP customers',
queueKey: 'vip',
criteria: { and: [{ customer_custom_isVipBool: { operator: 'equals', value: true } }], or: [] },
},
{
name: 'Billing emails',
queueKey: 'billing',
criteria: {
and: [{ conversation_channel: { operator: 'equals', value: 'email' } }],
or: [
{ message_subject: { operator: 'contains', value: 'invoice' } },
{ message_subject: { operator: 'contains', value: 'refund' } },
],
},
},
{
name: 'Support by region',
template: 'support-{{customer.custom.regionStr}}',
criteria: { and: [{ customer_custom_regionStr: { operator: 'is_set' } }], or: [] },
},
{
name: 'Holiday overflow',
queueKey: 'support-us',
enabled: false, // kept in order, switched on when needed
criteria: { and: [{ conversation_channel: { operator: 'equals', value: 'chat' } }], or: [] },
},
]priority (1 to 100, lower is more urgent) and itemSize (how much agent capacity one item uses) are copied onto each work item as it enters the queue. Check the fields and operators a rule can use with GET /v1/routing/queue-rules-criteria: strings take equals, not_equals and contains; is_set and not_set need no value. Custom attributes follow the <object>_custom_<name> pattern shown above.
2. Upsert queues by key
Look each queue up by key. Create it if nothing comes back, otherwise update it by ID. PUT needs name every time and changes only the fields you send.
/v1/routing/queues?key={key}
post/v1/routing/queues
put/v1/routing/queues/{id}
import { queues } from './routing.config'
async function syncQueues(): Promise<Map<string, string>> {
const ids = new Map<string, string>()
for (const q of queues) {
const { data } = await kfetch(`/v1/routing/queues?key=${encodeURIComponent(q.key)}`).then((r) => r.json())
const existing = data[0]
const res = existing
? await kfetch(`/v1/routing/queues/${existing.id}`, { method: 'PUT', body: JSON.stringify(q) })
: await kfetch('/v1/routing/queues', { method: 'POST', body: JSON.stringify(q) })
if (!res.ok) throw new Error(`queue ${q.key}: ${res.status} ${await res.text()}`)
ids.set(q.key, (await res.json()).data.id)
}
return ids
}A 409 Conflict on create means the org has as many queues as its subscription allows. On update, it means the queue was deleted in the app; deleted queues don't come back from the key lookup, so the next run creates a new one.
3. Sync the rules, then set the order
Rules have no key, so match them by name and keep names unique in your file. The list comes back in routing order, unpaginated. Point each rule at its queue in one of two ways:
PUT replaces the rule, so always send the whole thing: leaving out queue or queueKey clears it. New rules go to the end, so finish by sending every rule ID in the order you want.
/v1/routing/queue-rules
post/v1/routing/queue-rules
put/v1/routing/queue-rules/{id}
put/v1/routing/queue-rules/order
import { rules } from './routing.config'
async function syncRules(queueIds: Map<string, string>) {
const { data: current } = await kfetch('/v1/routing/queue-rules').then((r) => r.json())
const byName = new Map(current.map((r: { id: string; attributes: { name: string } }) => [r.attributes.name, r.id]))
const ordered: string[] = []
for (const rule of rules) {
const target = rule.template ? { queueKey: rule.template } : { queue: queueIds.get(rule.queueKey!) }
const body = JSON.stringify({ name: rule.name, criteria: rule.criteria, enabled: rule.enabled ?? true, ...target })
const id = byName.get(rule.name)
const res = id
? await kfetch(`/v1/routing/queue-rules/${id}`, { method: 'PUT', body })
: await kfetch('/v1/routing/queue-rules', { method: 'POST', body })
if (!res.ok) throw new Error(`rule ${rule.name}: ${res.status} ${await res.text()}`)
ordered.push((await res.json()).data.id)
byName.delete(rule.name)
}
// Rules that exist in Kustomer but not in the file keep their relative order, after yours.
const leftover = new Set(byName.values())
const rest = current.map((r: { id: string }) => r.id).filter((id: string) => leftover.has(id))
const res = await kfetch('/v1/routing/queue-rules/order', { method: 'PUT', body: JSON.stringify({ rules: [...ordered, ...rest] }) })
if (!res.ok) throw new Error(`rule order: ${res.status} ${await res.text()}`)
}The file owns each rule's enabled too, so a rule an admin switches off in the app is switched back on by the next sync unless the file says enabled: false. The order request must list every rule exactly once, up to 100, or it fails with 400; if someone adds or deletes a rule while the sync runs, run it again. The code above keeps rules an admin added in the app rather than deleting them; to make the file the only source of truth, DELETE /v1/routing/queue-rules/{id} the leftovers instead. Like queues, the number of rules is capped by your subscription, and creating one past the cap fails with 409.
4. Route conversations from your integration
If your integration creates conversations, for example from a custom channel, you can choose the queue yourself instead of relying on rules. Create a work item with the conversation as resource:
/v1/routing/work-items
put/v1/routing/work-items/{id}
interface WorkItem {
id: string
attributes: { rev: number }
relationships: { queue?: { data?: { id: string } } }
}
async function routeToQueue(conversationId: string, queueId: string, attempt = 1): Promise<WorkItem> {
const created = await kfetch('/v1/routing/work-items', {
method: 'POST',
body: JSON.stringify({ resource: { id: conversationId, type: 'conversation' }, queue: { id: queueId } }),
})
if (!created.ok) throw new Error(`work item for ${conversationId}: ${created.status} ${await created.text()}`)
const item: WorkItem = (await created.json()).data
if (item.relationships.queue?.data?.id === queueId) return item
// Routing got there first: the API returned the conversation's existing work item.
const res = await kfetch(`/v1/routing/work-items/${item.id}`, {
method: 'PUT',
body: JSON.stringify({ queue: { id: queueId }, rev: item.attributes.rev }),
})
if (res.status === 409 && attempt < 3) {
// It changed again between the read and the write. Back off, then re-read.
await new Promise((r) => setTimeout(r, 250 * 2 ** attempt))
return routeToQueue(conversationId, queueId, attempt + 1)
}
if (!res.ok) throw new Error(`move ${item.id}: ${res.status} ${await res.text()}`)
return (await res.json()).data
}A conversation has at most one work item that isn't completed. If it already has one, the create returns that item, still with 201, and ignores your queue, which is why the code compares queues before moving it. Each PUT does one thing and needs the rev you last read. To move a conversation an agent already holds, add "assignedTo": null so routing offers it from the new queue; otherwise only the label changes.
5. Show wait times and staffing
Two reads tell you how a queue is doing: the latest measured wait, and how many agents could take work right now.
get/v1/chat/queues/metrics/{ids}
get/v1/routing/queues/{id}/work-sessions?routable=true
const cache = new Map<string, { at: number; wait: number | null }>() // queue ID → latest wait
async function latestWaits(queueIds: string[]): Promise<Map<string, number | null>> {
const stale = queueIds.filter((id) => !(Date.now() - (cache.get(id)?.at ?? 0) < 60_000))
for (let i = 0; i < stale.length; i += 80) {
const res = await kfetch(`/v1/chat/queues/metrics/${stale.slice(i, i + 80).join(',')}?rangeFrom=3600`)
if (!res.ok) throw new Error(`queue metrics: ${res.status} ${await res.text()}`)
for (const q of (await res.json()).data) cache.set(q.id, { at: Date.now(), wait: q.attributes.latestWaitTimeSeconds })
}
return new Map(queueIds.map((id) => [id, cache.get(id)?.wait ?? null]))
}
async function routableAgents(queueId: string): Promise<number> {
let count = 0
for (let page = 1; ; page++) {
const { data, links } = await kfetch(`/v1/routing/queues/${queueId}/work-sessions?routable=true&page=${page}&pageSize=100`).then((r) => r.json())
count += data.length
if (!links?.next) return count
}
}latestWaitTimeSeconds is how long the most recent customer waited, in whole seconds, or null if the queue had no activity in the rangeFrom window (up to 86,400 seconds). It's a snapshot rather than a forecast, but it's a fair basis for "the current wait is about 3 minutes". The endpoint takes up to 80 queue IDs per call and is rate limited as a metrics endpoint, so the code batches IDs and caches each queue's wait for a minute. A routable session is an agent signed in to the queue, in an available status, with capacity left.
For a backlog count, page through GET /v1/routing/queues/{id}/work-items?status=queued and find the earliest firstEnterQueueAt. Snoozed items stay in the queue with paused: true but aren't routed, so count them separately.
6. Run it in production
Run the sync from CI
Keep
routing.config.tsin version control and apply it on merge, first to a sandbox and then to production. Keys make the same file work in both.Change priority deliberately
A queue's
priorityapplies to work that enters after the change; items already waiting keep the priority they entered with. To push a queue ahead during an incident, raise it, then restore the old value from your config.Leave the default queue alone
The default queue catches everything no rule matches. Rules can't target it, and the only field you can change on it is
restrictTransfersByUsers.Audit before reorganizing
List rules in routing order with their target queue names, using
GET /v1/routing/queues?all=trueso rules that point at deleted queues still resolve. The queue rules recipes show the export.
Related
- Queues, queue rules and work items reference
- Work sessions and team routing settings, for agent status and capacity
- Conditions and expressions, which use the same field naming as queue rules