> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Unified inbox SLA engine: first-response and resolution timers with breach alerts

> Attach an SLA policy to the inbox so first-response, next-reply, and resolution clocks are measurable per conversation — and route breach alerts to Slack, Teams, a signed webhook, an in-app notification, or a reassignment the moment a timer crosses its target.

# Unified inbox SLA engine: first-response and resolution timers with breach alerts

An SLA policy answers two questions continuously: *how long has this customer been waiting*, and *who should hear about it the moment the wait crosses the line you set*. A policy hangs three clocks on every conversation — first response, next response, and resolution — sweeps them every two minutes, and fires the breach action you configured exactly once per clock. The dashboard badge shows the clock turning overdue to the operator working the queue; the breach action is what carries the same event to the place your team actually watches — a Slack channel, a Teams connector, your own webhook endpoint, an escalation to a supervisor.

Everything here is per-workspace configuration. Creating, editing, and test-sending a policy or its breach action requires an owner or admin role; reading the clock state on a conversation is open to any operator.

## 1. Attach a policy

A policy names the targets and the breach action. This one gives the WhatsApp channel a 15-minute first-response target, an 8-hour resolution target, and posts every breach to a Slack Incoming Webhook:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/inbox/sla/policies \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "WhatsApp: 15-minute first response",
    "scope": "per_channel",
    "channel_filter": "whatsapp",
    "first_response_target_minutes": 15,
    "next_response_target_minutes": 60,
    "resolution_target_minutes": 480,
    "business_hours_only": false,
    "breach_action_type": "slack_webhook",
    "breach_action_config": {
      "webhook_url": "https://hooks.slack.com/services/T000/B000/XXXX"
    }
  }'
```

`scope` takes `org_wide`, `per_channel`, or `per_segment`, and one policy can be flagged `is_default` as the fallback when nothing more specific matches. With `business_hours_only: true` the clocks pause outside your workspace's posted hours — a 15-minute target set Friday afternoon is not breached before Monday morning.

Until you create a policy the workspace still reports against synthetic defaults — 4h first response, 24h resolution, business hours only — so the dashboard SLA badge works from day one, and your first policy replaces the defaults rather than switching the feature on.

## 2. The three clocks

Each conversation carries up to three timers, and each breaches independently:

* **First response** starts on the customer's first inbound message and stops when an operator sends the first outbound reply. An agent-initiated thread never false-breaches: if the customer has not written in yet, there is no clock to breach.
* **Next response** restarts whenever the customer replies again after that first answer, so follow-up waits are measured with the same rigor as the first.
* **Resolution** starts when the conversation opens and stops when it closes. It pauses while the thread sits in awaiting-customer, so a slow customer does not burn the agent's resolution budget.

Read the live state per conversation with `GET /inbox/sla/conversations/{conversationId}` — due-at, target minutes, and breach flags — the same state the dashboard SLA badge renders.

### Read many conversations at once

The per-conversation read works for a single thread in focus. Once you build anything that watches the whole queue — the dashboard's visible page, an alerting proxy rolling SLA state into your own ops surface — polling one conversation at a time falls apart. `POST /api/v1/inbox/sla/timers` evaluates the same clocks for up to 50 conversations in one request:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/inbox/sla/timers \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "conversations": [
      { "conversation_id": "conv_9f2ka", "channel_filter": "whatsapp" },
      { "conversation_id": "conv_7qh2z", "channel_filter": "email" }
    ]
  }'
```

Each entry can narrow policy resolution with `channel_filter`, and you can pass an optional `now_ms` to evaluate every clock against one fixed instant — handy when you need the batch to agree on what "now" is. The response carries one `results` entry per requested conversation, in input order, with each `state` mirroring the per-conversation payload exactly: due-at timestamps, resolved target minutes, breach flags with their first-breach timestamps, and the clock-active predicates — plus the org's `timezone` and `sla_enabled` resolved once for the whole batch:

```json theme={null}
{
  "results": [
    {
      "conversation_id": "conv_9f2ka",
      "status": "ok",
      "state": {
        "policy_id": "pol_4d1",
        "first_response_due_at": "2026-08-26T10:15:00.000Z",
        "first_response_breached": true,
        "first_response_clock_active": true,
        "resolution_breached": false
      }
    },
    {
      "conversation_id": "conv_7qh2z",
      "status": "not_found",
      "state": null
    }
  ],
  "timezone": "America/New_York",
  "sla_enabled": true
}
```

Notice the second row. The batch is failure-isolated: one bad id or one row that fails to evaluate comes back with `status` `"not_found"` or `"error"` and `state: null`, while every other row in the batch still evaluates normally. Even a whole-batch failure — the database dropping the query mid-read — returns 200 with every row marked `status:"error"`, never a 500. That is deliberate: an alert surface that goes blank on a transient blip hides breaches at exactly the wrong moment, so the contract guarantees you always get per-row data you can render, with degraded rows you can label "SLA unavailable" instead of losing the page. Handle all three statuses; never assume 200 means every row resolved.

## 3. Pick a breach action

`breach_action_type` chooses what fires when a timer crosses its target. The breach is detected by the recurring sweep and dispatched exactly once per clock, so a retried tick never double-posts:

| Action               | Fires                                                                           | Required config                                                  |
| -------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| `none`               | No extra side-effect — the overdue badge is enough                              | —                                                                |
| `reassign`           | Moves the conversation to a specific agent or an escalation queue               | `assignee_user_id` or `queue_id`                                 |
| `notify`             | Extra in-app notifications to listed supervisors' user ids                      | `user_ids[]`                                                     |
| `slack_alert`        | Posts to the workspace's connected Slack app, with an optional channel override | `channel_id` (optional)                                          |
| `slack_webhook`      | Posts a formatted card to a Slack Incoming Webhook URL                          | `webhook_url`                                                    |
| `teams_webhook`      | Posts an Adaptive Card to a Microsoft Teams connector URL                       | `webhook_url`                                                    |
| `webhook`            | Posts a signed JSON event to your own endpoint                                  | `url` (+ optional `secret`)                                      |
| `sentiment_escalate` | Escalates only when the conversation's latest sentiment is already negative     | `escalate_to` (`reassign` or `notify`) plus that action's config |

Two things are true for every webhook-shaped destination. First, only `https:` URLs are accepted, and the destination is re-validated at send time — URLs that resolve to internal or private network ranges are refused, so a breach alert can never be pointed at your own infrastructure. Second, the generic `webhook` action signs its body with the policy's `secret` (HMAC-SHA256, sent as `t=<unix>,v1=<hex>` on `X-Devotel-Signature`); verify it exactly like any other Orbit webhook delivery. Slack and Teams Incoming Webhook URLs have no operator-held secret, so those posts go unsigned by design.

`slack_webhook` and `teams_webhook` cover the common case where you'd rather paste one Incoming Webhook URL from the chat tool's admin UI than install the full OAuth app — the trade-off is that those posts are one-directional (no acknowledge or snooze buttons round-tripping back into Orbit).

Every destination in that table has the same shape: it announces the breach somewhere your team watches, and it cannot hear back. Acknowledgement is the counterpart on the Orbit side, covered next.

## 4. Acknowledge a breach after triage

When a breach fires, the operator who picks it up should ack it — the same gesture as acking a page in your on-call tool, recorded where the SLA state already lives. `POST /api/v1/inbox/sla/breaches/ack` stamps one or more fired alerts in a single request:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/inbox/sla/breaches/ack \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "acks": [
      {
        "conversation_id": "conv_9f2ka",
        "kind": "first_response",
        "note": "Shift lead paged in Slack, replying now"
      }
    ]
  }'
```

Each entry is one `(conversation, kind)` pair — `kind` is `first_response`, `next_response`, or `resolution` — with up to 50 per request and an optional `note` (≤280 characters) that lands in the audit trail. The stamp stores who acked and when, and it is what lets an alert surface distinguish "breached, nobody has seen this yet" from "breached, in hand." The endpoint is idempotent: re-acking the same clock simply replaces the timestamp and operator, so a retried client call never creates a duplicate record. The response reports each pair with `ok: true` on a stamped ack, or `ok: false` with an `error` — a conversation id you do not have fails its pair without affecting the rest of the batch.

Two interactions to get right. First, against the fire-once semantics of the breach action: it already fired exactly once, and acking does not rewind that — the clock keeps its first-breach timestamp, the chat post already went out, and the due-at does not move. Ack quiets the *alert* (the row is no longer un-triaged); it does not un-breach the conversation. Second, against the destinations table: chat posts are one-directional announcements out of Orbit, while ack flows entirely inside it — stamped on the conversation, shown to other operators, and written to the audit log. If your proxy builds its own alert surface on top of the bulk read above, acking through the API keeps Orbit's own badge and audit trail consistent with whatever state you maintain externally.

## 5. Build an escalation ladder with tiers

One breach action answers *the moment* a target is crossed. Most teams want a sequence: warn early, alert at the line, escalate hard when it's blown. `breach_action_tiers` is that sequence — up to five steps, each with its own action, fired in ascending `at_pct` order as the clock crosses each percentage of its target:

```json theme={null}
{
  "name": "Support ladder",
  "first_response_target_minutes": 30,
  "resolution_target_minutes": 480,
  "breach_action_tiers": [
    {
      "at_pct": 50,
      "action_type": "notify",
      "action_config": { "user_ids": ["user_shift_lead"] }
    },
    {
      "at_pct": 100,
      "action_type": "slack_alert",
      "action_config": { "channel_id": "C0123" }
    },
    {
      "at_pct": 200,
      "action_type": "reassign",
      "action_config": { "queue_id": "queue_escalation_tier2" }
    }
  ]
}
```

With a 30-minute first-response target, that ladder pings the shift lead at 15 minutes waiting, posts to Slack at 30, and drops the conversation into the tier-2 queue at 60. `at_pct` ranges from 1 to 500 and each value must be unique within the policy. Percentages below 100 fire before the breach line; above 100 they fire after it. Every tier fires at most once. Tiers run the directly-fireable actions — notify, reassign, Slack, and the webhook destinations — with the same payload shapes as a single-action policy; sentiment-gated escalation (next section) stays a policy-level action rather than a ladder step, and the API refuses to save one as a tier.

## 6. Gate escalations on sentiment

A neutral conversation crossing its resolution target is overdue; a conversation the sentiment analysis already scores as clearly unhappy is overdue *and* on fire. `sentiment_escalate` exists for the second case: when a breach fires, the workspace's per-message sentiment analysis is consulted, and only if the latest score sits at or below your threshold (between -0.9 and 0.0, defaulting to -0.3) does the escalation run — reassigning the thread or notifying a supervisor. Breaches on neutral or positive threads stop at the in-queue badge, which is usually the signal your team already has. When no sentiment is available yet for a conversation, the gate stays closed: a scoring gap never turns into an alert flood.

```json theme={null}
{
  "breach_action_type": "sentiment_escalate",
  "breach_action_config": {
    "sentiment_threshold": -0.3,
    "escalate_to": "notify",
    "user_ids": ["user_supervisor_1"]
  }
}
```

## 7. Test the destination before you rely on it

Before a policy goes live, fire the action against a synthetic test conversation — same payload shape, same signing, same destination validation as the real path, nothing written to any queue:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/inbox/sla/policies/test-action \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "breach_action_type": "teams_webhook",
    "breach_action_config": {
      "webhook_url": "https://outlook.office.com/webhook/abc/teams"
    }
  }'
```

The response reports whether the destination accepted the post, and the synthetic message appears in the target Slack channel or Teams connector labelled so you can tell it apart from a real breach. Verify it lands where you expect, then save the policy.

## See also

* [Omnichannel Inbox setup](/guides/inbox-setup) — channels, routing, macros, and the rest of the queue around the SLA engine
* [On-call alerting](/guides/oncall-alerting) — page a rotation when breach actions fire
* [Inbox API reference](/api-reference/inbox) — full request/response schemas for every endpoint above
* [Webhook delivery semantics](/concepts/webhook-delivery-semantics) — verifying Orbit signatures on the generic `webhook` action
