VitrinaAPI

Assign each message to the right team, with a clock on it

Teams, assignment rules and an SLA as one working configuration

A new conversation needs to land in someone's hands, fast. This recipe builds teams, assignment rules and an SLA as one configuration, not as separate pieces. By the end, a new message knows who owns it. And someone finds out if nobody answered in time.

Trap

Two rules can match the same conversation: the first one in the list wins, never the more specific one

Rules are evaluated in order and the first one that matches on source and channel wins. A rule with no members falls through to the next matching rule and, if none matches, to the channel. A rule with members but nobody online does not keep looking: the conversation stays unassigned.

Before you start

  • teams:write to create teams and add members.
  • routing:write for assignment rules. It is an admin-only scope, absent from an agent's default set.
  • slas:write and triggers:write for the clock and whatever fires when it runs out.
  • Roles and permissions inside a team live in Teams and roles; this recipe covers only routing.

1. Build the team

curl -X POST https://api.vitrinadev.com/api/v1/teams \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Atención Norte" }'
{
  "data": {
    "id": "42f4b285-802b-4d94-ac9e-ab3d5f7d615c",
    "name": "Atención Norte",
    "hours_mode": "workspace",
    "hours": null
  }
}
curl -X POST https://api.vitrinadev.com/api/v1/teams/42f4b285-802b-4d94-ac9e-ab3d5f7d615c/members \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "user_id": "3197957f-5fb6-4c7a-837d-1fe296bc548d", "role": "member" }'
{ "data": { "team_id": "42f4b285-802b-4d94-ac9e-ab3d5f7d615c", "user_id": "3197957f-5fb6-4c7a-837d-1fe296bc548d", "role": "member" } }

hours_mode starts at workspace: the team inherits the general schedule. For its own hours, send hours_mode: "override" together with hours in the same call. Without the mode, the window saves as null, and creation still answers 201 without a word about nothing being saved.

2. Decide which conversation goes to which team

curl -X POST https://api.vitrinadev.com/api/v1/assignment-rules \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Website to Atención Norte", "match_sources": ["website"], "team_id": "42f4b285-802b-4d94-ac9e-ab3d5f7d615c" }'
{ "data": { "id": "01a0ce19-5294-7415-9d9f-70bd68420b80", "name": "Website to Atención Norte", "priority": 0, "match_sources": ["website"], "team_id": "42f4b285-802b-4d94-ac9e-ab3d5f7d615c" } }

A rule carries exactly one destination: team_id or assignee_user_ids, never both. Empty match_sources and match_channels are a wildcard: they cover any origin or channel. A new rule always appends at the end; sending your own priority on create is a 400. The order gets changed afterward, in step 3.

3. Order the rules: whichever matches first, wins

curl -X PUT https://api.vitrinadev.com/api/v1/assignment-rules/order \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "ids": ["01a0ce19-6ad5-7eba-8d7e-e90a93f5ad8e", "01a0ce19-5294-7415-9d9f-70bd68420b80"] }'
{
  "data": [
    { "id": "01a0ce19-6ad5-7eba-8d7e-e90a93f5ad8e", "name": "WhatsApp por defecto", "priority": 0 },
    { "id": "01a0ce19-5294-7415-9d9f-70bd68420b80", "name": "Website to Atención Norte", "priority": 1 }
  ]
}

The change is atomic: it rewrites the priority of EVERY rule in the workspace at once, from the order of ids. The list has to carry every rule exactly once; a partial list is a 400 with nothing applied, never a half-done change.

If no rule matches, routing falls back to the channel: messaging_account.assignment_mode, configured from the app. If the channel does not have one either, it falls back to the workspace default. Rules carrying is_portal_default, the «Marketplace default» cards, always evaluate last, after any rule of your own.

4. Put a clock on it: define the SLA

curl -X POST https://api.vitrinadev.com/api/v1/slas \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Atención estándar",
    "target_first_response_minutes": 30,
    "target_resolution_minutes": 1440,
    "business_hours_only": true,
    "applies_to": { "channel": "whatsapp" }
  }'
{
  "data": {
    "id": "2dcb37cf-fe55-4f9c-9e2b-c8eedaa4b695",
    "name": "Atención estándar",
    "target_first_response_minutes": 30,
    "target_resolution_minutes": 1440,
    "business_hours_only": true,
    "applies_to": { "channel": "whatsapp" },
    "active": true
  }
}

The two targets are independent: a policy can set only a first-response target, only a resolution target, or both. business_hours_only pauses the clock outside the team's hours, the same hours/hours_mode from step 1, instead of counting wall-clock time.

applies_to decides which conversations the policy applies to. It is a free filter, with the same keys your routing already uses: channel, brand, priority.

5. React when the clock runs out

curl -X POST https://api.vitrinadev.com/api/v1/triggers \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Alert on SLA breach",
    "event": "sla_breached",
    "actions": [{ "type": "assign_team", "value": "42f4b285-802b-4d94-ac9e-ab3d5f7d615c" }]
  }'
{
  "data": {
    "id": "d6b003a7-6641-4415-843a-028940e0681b",
    "name": "Alert on SLA breach",
    "event": "sla_breached",
    "actions": [{ "type": "assign_team", "value": "42f4b285-802b-4d94-ac9e-ab3d5f7d615c" }],
    "enabled": true,
    "run_count": 0
  }
}

sla_breached is one of twelve events a trigger can listen for. The rest cover the rest of a conversation's cycle: created, reopened, tagged, idle for hours. Conditions are optional: with no conditions, the action runs on every breach. With them, it filters by field, operator and value, combined with all or any. run_count climbs on every fire, so it is how you confirm the trigger actually ran.

To confirm it without opening the app, subscribe to the sla.breached and conversation.assigned events; the full catalog of events and signatures lives in Webhooks.

In the app: the rules live under Configuración → Bandeja → Asignación, numbered in the same priority order PUT /assignment-rules/order exposes.

Vitrina's Asignación screen: six rules numbered by priority, one switched off, and the next tiers of the cascade

The clock is a different screen. SLAs are defined under Configuración → Bandeja → SLAs, and triggers under Configuración → IA y automatización → Triggers:

Vitrina's SLAs screen: the workspace's policies with their target times and which channels and teams they apply to

When it fails

Reordering with an incomplete list answers 400 and moves nothing:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "ids must list every rule of the workspace exactly once (expected 2, got 1)."
  }
}

The message carries the expected count against what it received. Before retrying, fetch GET /assignment-rules again and build the full list from there, instead of reordering only the rules that changed.

What this recipe leaves out

Macros are canned replies, with automated steps built in. They get created over the API the same way a rule does. Applying one, though, is still an inbox action: there is no matching call. Creation and its fields live in Macros and assignment rules. The detail of every trigger event and condition lives in SLAs and automations.

On this page