Build teams and custom roles
The teams that route work, and the roles that decide what each member sees.
A team receives the work; a custom role decides what the person receiving it may do. They're two separate configuration resources and they're almost always edited together.
Teams
A channel's conversation lands with the team that channel defaults to, unless an assignment rule says otherwise. A ticket carries its own team_id. A team also holds a coverage schedule.
Reading needs teams:read; writing, teams:write.
curl -X POST https://api.vitrinadev.com/api/v1/teams \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Technical support",
"hours_mode": "override",
"hours": { "mon": { "on": true, "start": "09:00", "end": "19:00" } }
}'{
"data": {
"id": "cccccccc-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Technical support",
"brand": null,
"hours_mode": "override",
"hours": { "mon": { "on": true, "start": "09:00", "end": "19:00" } },
"created_at": "2026-09-22T13:00:00.000Z",
"updated_at": "2026-09-22T13:00:00.000Z"
}
}hours_mode decides where that schedule comes from. On "workspace", the default, the team follows the workspace's; on "override" it follows its own, and hours spells it out as a partial weekday map. That own schedule feeds the SLA clock, the is_business_hours check in automations, and the AI agent's "office closed" tone. Timezone and holidays always come from the workspace and never get overridden.
Team members
curl -X POST https://api.vitrinadev.com/api/v1/teams/cccccccc-0000-4000-8000-000000000001/members \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{ "user_id": "11111111-0000-4000-8000-000000000001", "role": "member" }'user_id has to be an active member of the workspace already. An unknown id, one from another workspace, or a deactivated one is refused with a 400. The role here (lead or member) is a label on the team rather than a permission; what the person may do is decided by their workspace role or their custom role.
Trap
Assigned tickets stay assigned
DELETE /teams/{id}/members/{membershipId} removes only the team membership.
The person keeps their account and any other team they're on, and the tickets
already assigned to them stay with them. Their assignee_user_id isn't
cleared, so a team change never orphans work.
Removing someone from the workspace works differently. There,
DELETE /memberships/{id} does hand over the person's open work, because they
lose the ability to see it. That's in Team.
Custom roles
A custom role sets a membership's effective access. A membership pointed at one gets exactly that role's scopes, with no inherited floor from the base role, plus its two visibility ceilings, resolved on every request.
Reading needs roles:read; writing, roles:write.
curl -X POST https://api.vitrinadev.com/api/v1/roles \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Support agent",
"scopes": ["conversations:read", "conversations:write", "tickets:read", "tickets:write"],
"record_visibility": "assigned",
"stock_visibility": "all"
}'{
"data": {
"id": "d2d2d2d2-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Support agent",
"scopes": ["conversations:read", "conversations:write", "tickets:read", "tickets:write"],
"record_visibility": "assigned",
"stock_visibility": "all",
"created_at": "2026-09-22T13:00:00.000Z",
"updated_at": "2026-09-22T13:00:00.000Z"
}
}The two ceilings are independent:
| Field | Values | What it narrows |
|---|---|---|
record_visibility | all, assigned_unassigned, assigned | Which conversations, tickets and leads the member sees, keyed on who they're assigned to. |
stock_visibility | all, own_locations | Whether catalog reads narrow to the member's own locations. The fact lives on the membership; the policy lives on the role. |
Built-in roles (owner, admin, supervisor, agent) aren't custom roles, and they always resolve to all on both axes.
Trap
You can't build a role wider than your own
Creating or editing a role is checked against the caller's own access. A scope
they don't hold, a record_visibility wider than theirs, or a
stock_visibility wider than theirs is a 403. scopes also accepts only
known permissions that aren't owner-only. An unrestricted owner or admin is
unaffected: the ceiling reaches only a caller who is already restricted.
Deleting a role that still has active members is a 409. Empty it first, or pass ?reassign_to=<role id> to move everyone onto another role in one step, checked on all three axes the way a PUT is.
The full contracts live in the Teams and Custom roles reference.