Custom roles
The per-role scope and visibility catalog — effective access, not a label.
Download the full API pública projection: openapi.json.
Build teams and custom roles explains this resource in prose, with runnable examples.
| Method | Path | What it does |
|---|---|---|
GET | /roles | List the workspace’s custom roles |
POST | /roles | Create a custom role |
DELETE | /roles/{id} | Delete a custom role |
GET | /roles/{id} | Fetch one custom role |
PUT | /roles/{id} | Update a custom role |
GET /roles
List the workspace’s custom roles
Newest first, with a total. Unpaginated — a workspace has a handful of custom roles. Built-in tiers are not returned: they are not rows on this table.
curl https://api.vitrinadev.com/api/v1/roles \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": [
{
"id": "d2d2d2d2-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Vendedor sucursal",
"description": "Ve solo sus propias conversaciones y el stock de su sucursal.",
"scopes": [
"conversations:read",
"conversations:write",
"leads:read",
"leads:write"
],
"record_visibility": "assigned",
"stock_visibility": "own_locations",
"created_at": "2026-01-10T13:00:00.000Z",
"updated_at": "2026-09-10T13:00:00.000Z"
}
],
"meta": {
"total": 1
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /roles
Create a custom role
scopes is optional; omitting it creates an EMPTY role, which grants nothing and is accepted. Only known, non-owner-only permissions are allowed — the wildcard *, an unknown string and the owner-only scopes (billing:write, tenant:delete) are all a 400.
record_visibility is all | assigned_unassigned | assigned and narrows conversations, tickets and leads by assignee. stock_visibility is all | own_locations and narrows VEHICLE reads to the sucursales on the member’s own workspace_member.location_ids — a member on an own_locations role with no sucursales sees no stock at all, and a vehicle with no location_id is invisible to such a member by definition. Both default to all.
Every field is priced against the caller’s own access: a scope you do not hold, a record_visibility wider than yours, or a stock_visibility wider than yours is a 403. Owners and unrestricted admins are unaffected — the ceilings can only ever bite a caller who is themselves restricted. A narrowly-scoped sk_ key cannot set record_visibility at all (a credential carries no such ceiling of its own, so there is nothing to bound the grant against); use a signed-in user.
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | yes | mín. 1, máx. 200 |
description | string | null | — | máx. 2000 |
tint | string | null | — | máx. 40 |
scopes | string[] | — | |
record_visibility | all \ | assigned_unassigned \ | assigned |
stock_visibility | all \ | own_locations | — |
curl -X POST https://api.vitrinadev.com/api/v1/roles \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Vendedor sucursal",
"scopes": [
"conversations:read",
"conversations:write",
"leads:read",
"leads:write"
],
"record_visibility": "assigned",
"stock_visibility": "own_locations"
}'Example response (201)
{
"data": {
"id": "d2d2d2d2-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Vendedor sucursal",
"description": "Ve solo sus propias conversaciones y el stock de su sucursal.",
"scopes": [
"conversations:read",
"conversations:write",
"leads:read",
"leads:write"
],
"record_visibility": "assigned",
"stock_visibility": "own_locations",
"created_at": "2026-01-10T13:00:00.000Z",
"updated_at": "2026-09-10T13:00:00.000Z"
}
}Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429
DELETE /roles/{id}
Delete a custom role
A role that still has members is a 409: unlinking them would resolve each one to their BUILT-IN tier with record_visibility: all and stock_visibility: all, i.e. silently promote them. Empty the role first, or pass ?reassign_to=<role id> to move every member onto a named replacement in one step — the replacement is a grant and is priced on all three axes exactly as the body of a PUT is. reassign_to may not name the role being deleted (400), and reassignment to a built-in tier is not supported.
The delete also REVOKES every still-pending invitation that named the role, in the same transaction: that foreign key is ON DELETE SET NULL, so such an invitation would otherwise accept on its plain built-in tier. Accepted, expired and already-revoked invitations are untouched.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes | |
reassign_to | query | uuid | no |
curl -X DELETE https://api.vitrinadev.com/api/v1/roles/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Answers: 204 · 400 · 401 · 403 · 404 · 409 · 429
GET /roles/{id}
Fetch one custom role
The role including both visibility ceilings. A role belonging to another workspace is a 404, never a 403 — nothing here confirms a guessed id.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl https://api.vitrinadev.com/api/v1/roles/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"id": "d2d2d2d2-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Vendedor sucursal",
"description": "Ve solo sus propias conversaciones y el stock de su sucursal.",
"scopes": [
"conversations:read",
"conversations:write",
"leads:read",
"leads:write"
],
"record_visibility": "assigned",
"stock_visibility": "own_locations",
"created_at": "2026-01-10T13:00:00.000Z",
"updated_at": "2026-09-10T13:00:00.000Z"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
PUT /roles/{id}
Update a custom role
A PUT that behaves as a PATCH: only the fields present are applied and at least one is required. scopes is a FULL REPLACEMENT array, not a merge — but only the scopes being ADDED are priced, so an admin who lacks X can still rename a role that already carries it.
The change takes effect on the next request of every member already on the role, including the caller.
record_visibility is all | assigned_unassigned | assigned and narrows conversations, tickets and leads by assignee. stock_visibility is all | own_locations and narrows VEHICLE reads to the sucursales on the member’s own workspace_member.location_ids — a member on an own_locations role with no sucursales sees no stock at all, and a vehicle with no location_id is invisible to such a member by definition. Both default to all.
Every field is priced against the caller’s own access: a scope you do not hold, a record_visibility wider than yours, or a stock_visibility wider than yours is a 403. Owners and unrestricted admins are unaffected — the ceilings can only ever bite a caller who is themselves restricted. A narrowly-scoped sk_ key cannot set record_visibility at all (a credential carries no such ceiling of its own, so there is nothing to bound the grant against); use a signed-in user.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | — | mín. 1, máx. 200 |
description | string | null | — | máx. 2000 |
tint | string | null | — | máx. 40 |
scopes | string[] | — | |
record_visibility | all \ | assigned_unassigned \ | assigned |
stock_visibility | all \ | own_locations | — |
curl -X PUT https://api.vitrinadev.com/api/v1/roles/<id> \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"record_visibility": "assigned_unassigned"
}'Example response (200)
{
"data": {
"id": "d2d2d2d2-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Vendedor sucursal",
"description": "Ve solo sus propias conversaciones y el stock de su sucursal.",
"scopes": [
"conversations:read",
"conversations:write",
"leads:read",
"leads:write"
],
"record_visibility": "assigned_unassigned",
"stock_visibility": "own_locations",
"created_at": "2026-01-10T13:00:00.000Z",
"updated_at": "2026-09-10T13:00:00.000Z"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429