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.

routing.config.ts
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.

get/v1/routing/queues?key={key} post/v1/routing/queues put/v1/routing/queues/{id}
Node.js
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:

FieldUse
queueA fixed queue ID, here looked up from the keys you just synced. It can't be the default queue.
queueKeyA Handlebars template filled in from the conversation, customer, company and latest message, then matched against queue keys. support-{{customer.custom.regionStr}} sends a customer with regionStr: "eu" to support-eu. If the result matches no queue, the conversation goes to the default queue.

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.

get/v1/routing/queue-rules post/v1/routing/queue-rules put/v1/routing/queue-rules/{id} put/v1/routing/queue-rules/order
Node.js
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:

post/v1/routing/work-items put/v1/routing/work-items/{id}
Node.js
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
Node.js
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

  1. Run the sync from CI

    Keep routing.config.ts in version control and apply it on merge, first to a sandbox and then to production. Keys make the same file work in both.

  2. Change priority deliberately

    A queue's priority applies 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.

  3. 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.

  4. Audit before reorganizing

    List rules in routing order with their target queue names, using GET /v1/routing/queues?all=true so rules that point at deleted queues still resolve. The queue rules recipes show the export.