Equipo
Los miembros del workspace y sus invitaciones: alta, cambio de rol o sucursal, y baja — que siempre reparte el trabajo pendiente de la persona en lugar de dejarlo huérfano.
Descarga la proyección completa de la API pública: openapi.json.
Administrar el equipo explica este recurso en prosa, con ejemplos ejecutables.
| Método | Ruta | Qué hace |
|---|---|---|
GET | /memberships | List user memberships for the active tenant |
POST | /memberships | Add a user to the tenant with a role |
DELETE | /memberships/{id} | Remove a user from the tenant |
PUT | /memberships/{id} | Change a user role, account status, and/or profile identity (display_name / phone / avatar_url) |
POST | /memberships/{id}/avatar | Upload a teammate's avatar (admin-set photo) |
GET | /memberships/{id}/handover-preview | What unfinished work this member holds, and who could inherit it |
GET | /memberships/invitations | List the pending invitations for this workspace |
POST | /memberships/invitations | Invite somebody to the workspace by email |
DELETE | /memberships/invitations/{id} | Revoke a pending invitation |
POST | /memberships/invitations/{id}/resend | Re-issue a pending invitation with a fresh token and email |
GET /memberships
List user memberships for the active tenant
Every active seat in the workspace — role, custom role, account status and the profile identity (display_name/phone/avatar_url) each member carries.
curl https://api.vitrinadev.com/api/v1/memberships \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": [
{
"id": "11111111-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"user_id": "11111111-0000-4000-8000-000000000001",
"role": "agent",
"custom_role_id": "d2d2d2d2-0000-4000-8000-000000000001",
"custom_role_name": "Vendedor sucursal",
"status": "active",
"display_name": "Camila Rojas",
"phone": "+56912345001",
"avatar_url": null,
"location_ids": [
"b1b1b1b1-0000-4000-8000-000000000001"
],
"created_at": "2026-01-10T13:00:00.000Z",
"updated_at": "2026-09-10T13:00:00.000Z"
}
],
"meta": {
"total": 1
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /memberships
Add a user to the tenant with a role
Grants an EXISTING Supabase user (user_id) a seat directly, with no invitation step — the caller already knows who they are onboarding. role and custom_role_id are independent, same as on the invitation endpoints, and both are priced against the caller’s own escalation ceiling: a credential can never grant more than the member behind it holds.
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
user_id | uuid | sí | |
role | owner \ | admin \ | supervisor \ |
custom_role_id | string | null | — | |
invited_email | string | null | — |
curl -X POST https://api.vitrinadev.com/api/v1/memberships \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"user_id": "e6e6e6e6-0000-4000-8000-000000000002",
"role": "agent",
"custom_role_id": "d2d2d2d2-0000-4000-8000-000000000001",
"location_ids": [
"b1b1b1b1-0000-4000-8000-000000000001"
]
}'Ejemplo de respuesta (201)
{
"data": {
"id": "e6e6e6e6-0000-4000-8000-000000000002",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"user_id": "e6e6e6e6-0000-4000-8000-000000000002",
"role": "agent",
"custom_role_id": "d2d2d2d2-0000-4000-8000-000000000001",
"custom_role_name": "Vendedor sucursal",
"status": "active",
"display_name": "Camila Rojas",
"phone": "+56912345001",
"avatar_url": null,
"location_ids": [
"b1b1b1b1-0000-4000-8000-000000000001"
],
"created_at": "2026-01-10T13:00:00.000Z",
"updated_at": "2026-09-10T13:00:00.000Z"
}
}Responde: 201 · 400 · 401 · 403 · 404 · 409 · 429
DELETE /memberships/{id}
Remove a user from the tenant
Also disposes of the member’s unfinished work, because leaving it assigned to a removed user puts it in nobody’s inbox. handover is unassign (default — threads surface under “Sin asignar”, nobody is notified), user (one named inheritor, requires handover_assignee_id) or round_robin (dealt over the tenant’s sellers). Every move goes through the normal assignment path, so the new owner gets the usual notification. Returns the receipt of what moved; resolved/closed conversations and won/lost leads keep their original owner.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí | |
handover | query | unassign \ | user \ | round_robin |
handover_assignee_id | query | uuid | no |
curl -X DELETE https://api.vitrinadev.com/api/v1/memberships/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"mode": "user",
"conversationsMoved": 6,
"leadsMoved": 3,
"perAssignee": {
"e6e6e6e6-0000-4000-8000-000000000002": 9
}
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
PUT /memberships/{id}
Change a user role, account status, and/or profile identity (display_name / phone / avatar_url)
Role/status changes are gated by the escalation guards in membership-guard.service (rank + grant-ceiling). display_name, phone and avatar_url write the target’s user_profile instead — gated by the same act-on-target rank check, but with no self-action restriction (editing your own identity is always allowed). avatar_url: null removes the photo; a string must be an http(s) URL (manual set — for a file upload, use POST /memberships/\{id\}/avatar).
location_ids is the SUCURSAL DEL MIEMBRO — which branches this person is posted to. It is independent of the role fields (moving branch is not a role change, so it may be sent on its own) and every id must name a location in this workspace, inactive ones included; an unknown or foreign id is a 400. null and [] both clear the posting. It only takes effect while the member’s role carries stock_visibility: own_locations, and a caller who is themselves branch-scoped may only hand out sucursales they can see (403).
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
role | owner \ | admin \ | supervisor \ |
custom_role_id | string | null | — | |
account_status | active \ | inactive \ | suspended |
display_name | string | null | — | máx. 200 |
phone | string | null | — | máx. 40 |
avatar_url | string | null | — | máx. 2000 |
location_ids | array | null | — |
curl -X PUT https://api.vitrinadev.com/api/v1/memberships/<id> \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"custom_role_id": "d2d2d2d2-0000-4000-8000-000000000001",
"location_ids": [
"b1b1b1b1-0000-4000-8000-000000000001"
]
}'Ejemplo de respuesta (200)
{
"data": {
"id": "11111111-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"user_id": "11111111-0000-4000-8000-000000000001",
"role": "agent",
"custom_role_id": "d2d2d2d2-0000-4000-8000-000000000001",
"custom_role_name": "Vendedor sucursal",
"status": "active",
"display_name": "Camila Rojas",
"phone": "+56912345001",
"avatar_url": null,
"location_ids": [
"b1b1b1b1-0000-4000-8000-000000000001"
],
"created_at": "2026-01-10T13:00:00.000Z",
"updated_at": "2026-09-10T13:00:00.000Z"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /memberships/{id}/avatar
Upload a teammate's avatar (admin-set photo)
multipart/form-data with a file part (PNG/JPEG/WEBP/GIF, max 5MB) — same multer config/validation as POST /me/avatar, but for a TARGET member rather than the caller. Gated by memberships:write AND the same rank guard (assertCanActOnTarget) as PUT /memberships/\{id\}: an admin may set an agent’s photo, but not one belonging to a member who outranks them. Stores the file and upserts the target’s user_profile.avatar_url.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl -X POST https://api.vitrinadev.com/api/v1/memberships/<id>/avatar \
-H "Authorization: Bearer $VITRINA_KEY" \
-F "file=@/path/to/file.png"Ejemplo de respuesta (200)
{
"data": {
"avatar_url": "https://api.vitrinadev.com/api/v1/public/avatars/11111111-0000-4000-8000-000000000001/f3b2a1c4-6d5e-4a8f-9c0b-2e1d3f4a5b6c.jpg"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /memberships/{id}/handover-preview
What unfinished work this member holds, and who could inherit it
Read-only. Returns the member’s open conversations and open leads plus the candidate assignees a round_robin handover would deal over (active members already present in some routing roster, with their current load). Drives the confirmation dialog on removal.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl https://api.vitrinadev.com/api/v1/memberships/<id>/handover-preview \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"userId": "e6e6e6e6-0000-4000-8000-000000000001",
"openConversations": 6,
"openLeads": 3,
"candidates": [
{
"userId": "e6e6e6e6-0000-4000-8000-000000000002",
"openConversations": 2
},
{
"userId": "11111111-0000-4000-8000-000000000001",
"openConversations": 5
}
]
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /memberships/invitations
List the pending invitations for this workspace
PENDING ONLY — accepted invites are teammates and belong on GET /memberships; revoked/expired rows (including the one a resend retires) are noise that made a single resend look like two invitations. Each row carries custom_role_name (resolved from custom_role_id, null when the role was deleted since) and accept_url, the signup link the admin can copy when email delivery is unconfigured or flaky.
curl https://api.vitrinadev.com/api/v1/memberships/invitations \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": [
{
"id": "e5e5e5e5-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"email": "[email protected]",
"role": "agent",
"custom_role_id": "d2d2d2d2-0000-4000-8000-000000000001",
"custom_role_name": "Vendedor sucursal",
"location_ids": [
"b1b1b1b1-0000-4000-8000-000000000001"
],
"invited_by": "11111111-0000-4000-8000-000000000001",
"accept_url": "https://app.vitrinadev.com/invite/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"created_at": "2026-09-10T13:00:00.000Z",
"expires_at": "2026-09-17T13:00:00.000Z"
}
],
"meta": {
"total": 1
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /memberships/invitations
Invite somebody to the workspace by email
Writes a tenant_invitation and sends the email; the link is also returned as accept_url, so a workspace with no mail provider can still onboard. A pending invitation for the same address is revoked first, so this is also "re-invite".
role and custom_role_id are INDEPENDENT, exactly as on POST /memberships: role is the escalation rank, the custom role decides effective access on accept. Both are priced against the caller’s own ceiling here rather than at accept — the accept is a token-only request and has no admin to charge.
location_ids is the SUCURSAL DEL MIEMBRO the invitee lands posted to (ADR 0102), copied onto workspace_member.location_ids when they click through, so a «Solo sus sucursales» seat is narrowed from the first login. Same rules as PUT /memberships/\{id\}: every id must name a location in this workspace, inactive ones included; an unknown or foreign id is a 400; null/[] mean "posted nowhere"; and a caller who is themselves branch-scoped may only hand out branches they can see (403).
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
email | string | sí | email, máx. 320 |
role | owner \ | admin \ | supervisor \ |
custom_role_id | string | null | — | |
location_ids | array | null | — | |
name | string | — | máx. 200 |
phone | string | — | máx. 40 |
curl -X POST https://api.vitrinadev.com/api/v1/memberships/invitations \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"role": "agent",
"custom_role_id": "d2d2d2d2-0000-4000-8000-000000000001",
"location_ids": [
"b1b1b1b1-0000-4000-8000-000000000001"
]
}'Ejemplo de respuesta (201)
{
"data": {
"id": "e5e5e5e5-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"email": "[email protected]",
"role": "agent",
"custom_role_id": "d2d2d2d2-0000-4000-8000-000000000001",
"custom_role_name": "Vendedor sucursal",
"location_ids": [
"b1b1b1b1-0000-4000-8000-000000000001"
],
"invited_by": "11111111-0000-4000-8000-000000000001",
"accept_url": "https://app.vitrinadev.com/invite/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"created_at": "2026-09-10T13:00:00.000Z",
"expires_at": "2026-09-17T13:00:00.000Z"
}
}Responde: 201 · 400 · 401 · 403 · 404 · 409 · 429
DELETE /memberships/invitations/{id}
Revoke a pending invitation
The token stops working immediately. Tenant-scoped: an id from another workspace is a 404, not a silent no-op.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl -X DELETE https://api.vitrinadev.com/api/v1/memberships/invitations/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Responde: 204 · 400 · 401 · 403 · 404 · 409 · 429
POST /memberships/invitations/{id}/resend
Re-issue a pending invitation with a fresh token and email
Revokes the old row and writes a new one carrying the SAME role, custom role, sucursales, name and phone — a resend is the same invitation, not a blank one. Only the fresh row is listed, so the "Invitados" tab still shows one line. An already-accepted invitation is a 400.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl -X POST https://api.vitrinadev.com/api/v1/memberships/invitations/<id>/resend \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"id": "e5e5e5e5-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"email": "[email protected]",
"role": "agent",
"custom_role_id": "d2d2d2d2-0000-4000-8000-000000000001",
"custom_role_name": "Vendedor sucursal",
"location_ids": [
"b1b1b1b1-0000-4000-8000-000000000001"
],
"invited_by": "11111111-0000-4000-8000-000000000001",
"accept_url": "https://app.vitrinadev.com/invite/b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5",
"created_at": "2026-09-10T13:00:00.000Z",
"expires_at": "2026-09-17T13:00:00.000Z"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429