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

Source: https://developer.kustomer.com/docs/guides/queues-and-routing

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.

```mermaid
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](https://developer.kustomer.com/docs/get-started/making-requests#idempotency-and-retries), which adds the auth header and retries `429`s and `5xx`s.

## How routing picks a queue

When a conversation needs an agent, Kustomer creates a [work item](https://developer.kustomer.com/api/queues-and-routing#work-items) for it. If nothing has set a queue yet, routing checks the org's [queue rules](https://developer.kustomer.com/api/queues-and-routing#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.

```ts title="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`](https://developer.kustomer.com/api/queues-and-routing/get-queue-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}` ([reference](https://developer.kustomer.com/api/queues-and-routing/get-queues))

`POST /v1/routing/queues` ([reference](https://developer.kustomer.com/api/queues-and-routing/create-queue))

`PUT /v1/routing/queues/{id}` ([reference](https://developer.kustomer.com/api/queues-and-routing/update-queue-by-id))

```ts title="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:

| Field      | Use                                                                                                                                                                                                                                                                                                        |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `queue`    | A fixed queue ID, here looked up from the keys you just synced. It can't be the default queue.                                                                                                                                                                                                             |
| `queueKey` | A 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` ([reference](https://developer.kustomer.com/api/queues-and-routing/get-queue-rules))

`POST /v1/routing/queue-rules` ([reference](https://developer.kustomer.com/api/queues-and-routing/create-queue-rule))

`PUT /v1/routing/queue-rules/{id}` ([reference](https://developer.kustomer.com/api/queues-and-routing/update-queue-rule))

`PUT /v1/routing/queue-rules/order` ([reference](https://developer.kustomer.com/api/queues-and-routing/update-queue-rule-order))

```ts title="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`.

> **Try it in a sandbox first:** Rule changes apply to conversations routed after the change; conversations already in a queue stay where they are. Run the sync against a sandbox, send a few test conversations, then run it against production.

## 4. Route conversations from your integration

If your integration creates conversations, for example from a [custom channel](https://developer.kustomer.com/docs/guides/custom-channel-messages), 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` ([reference](https://developer.kustomer.com/api/queues-and-routing/create-work-item))

`PUT /v1/routing/work-items/{id}` ([reference](https://developer.kustomer.com/api/queues-and-routing/update-work-item))

```ts title="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}` ([reference](https://developer.kustomer.com/api/queues-and-routing/queue-metrics))

`GET /v1/routing/queues/{id}/work-sessions?routable=true` ([reference](https://developer.kustomer.com/api/queues-and-routing/get-work-sessions-by-queue-id))

```ts title="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`](https://developer.kustomer.com/api/queues-and-routing/get-work-items-in-a-queue) 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.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.

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

#### 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=true` so rules that point at deleted queues still resolve. The [queue rules recipes](https://developer.kustomer.com/api/queues-and-routing#queue-rules) show the export.

## Related

- [Queues](https://developer.kustomer.com/api/queues-and-routing#queues), [queue rules](https://developer.kustomer.com/api/queues-and-routing#queue-rules) and [work items](https://developer.kustomer.com/api/queues-and-routing#work-items) reference
- [Work sessions](https://developer.kustomer.com/api/queues-and-routing#work-sessions) and [team routing settings](https://developer.kustomer.com/api/queues-and-routing#team-routing-settings), for agent status and capacity
- [Conditions and expressions](https://developer.kustomer.com/docs/workflows/conditions-and-expressions), which use the same field naming as queue rules
