VitrinaAPI

Team

The workspace's members and their invitations: add one, change a role or branch, and remove one — removal always hands off the person's open work instead of stranding it.

Beta
This may change at any time, with a changelog entry and a notice to recent callers — see versioning.

Download the full API pública projection: openapi.json.

Manage the workspace's people explains this resource in prose, with runnable examples.

MethodPathWhat it does
GET/membershipsList user memberships for the active tenant
POST/membershipsAdd 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}/avatarUpload a teammate's avatar (admin-set photo)
GET/memberships/{id}/handover-previewWhat unfinished work this member holds, and who could inherit it
GET/memberships/invitationsList the pending invitations for this workspace
POST/memberships/invitationsInvite somebody to the workspace by email
DELETE/memberships/invitations/{id}Revoke a pending invitation
POST/memberships/invitations/{id}/resendRe-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"

Example response (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
  }
}

Answers: 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.

Body

FieldTypeRequiredConstraints
user_iduuidyes
roleowner \admin \supervisor \
custom_role_idstring | null—
invited_emailstring | null—email
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"
    ]
  }'

Example response (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"
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
idpathuuidyes
handoverqueryunassign \user \round_robin
handover_assignee_idqueryuuidno
curl -X DELETE https://api.vitrinadev.com/api/v1/memberships/<id> \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": {
    "mode": "user",
    "conversationsMoved": 6,
    "leadsMoved": 3,
    "perAssignee": {
      "e6e6e6e6-0000-4000-8000-000000000002": 9
    }
  }
}

Answers: 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).

ParameterInTypeRequiredConstraints
idpathuuidyes

Body

FieldTypeRequiredConstraints
roleowner \admin \supervisor \
custom_role_idstring | null—
account_statusactive \inactive \suspended
display_namestring | null—máx. 200
phonestring | null—máx. 40
avatar_urlstring | null—máx. 2000
location_idsarray | 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"
    ]
  }'

Example response (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"
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl -X POST https://api.vitrinadev.com/api/v1/memberships/<id>/avatar \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -F "file=@/path/to/file.png"

Example response (200)

{
  "data": {
    "avatar_url": "https://api.vitrinadev.com/api/v1/public/avatars/11111111-0000-4000-8000-000000000001/f3b2a1c4-6d5e-4a8f-9c0b-2e1d3f4a5b6c.jpg"
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl https://api.vitrinadev.com/api/v1/memberships/<id>/handover-preview \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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
      }
    ]
  }
}

Answers: 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"

Example response (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
  }
}

Answers: 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).

Body

FieldTypeRequiredConstraints
emailstringyesemail, máx. 320
roleowner \admin \supervisor \
custom_role_idstring | null—
location_idsarray | null—
namestring—máx. 200
phonestring—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"
    ]
  }'

Example response (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"
  }
}

Answers: 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.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl -X DELETE https://api.vitrinadev.com/api/v1/memberships/invitations/<id> \
  -H "Authorization: Bearer $VITRINA_KEY"

Answers: 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.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl -X POST https://api.vitrinadev.com/api/v1/memberships/invitations/<id>/resend \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (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"
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

On this page