voepy

Team & roles

Invite people into your account, give each one a role, and let role-based access control (RBAC) decide what they can touch. Roles are just bundles of scopes; scopes are the same permission strings your API keys carry. Learn one model, use it everywhere.

All paths under /v1/. Authorization: Bearer <api-key> required.

How it fits together

tenant ──< members (users) ──> role ──< scopes
                                  ▲
                                  └── built-in (owner/admin/billing/member)
                                      or custom (yours)
  • A tenant is your account.
  • A member is a user joined to your tenant through a membership. One membership = one user + one role.
  • A role is a named bundle of scopes (e.g. calls:write, billing:read).
  • A scope is a single permission string. The wildcard * means "every scope."

Every privileged request is checked against a single scope. A member's role decides which scopes they carry; an API key carries its own scope list (chosen at issue time, defaulting to *). Same enforcement either way — the permission string is the unit of authorization. See Authentication for how API-key scopes are set.

Two scopes are also verification-gated: even with the right role, they're refused until your tenant's email is verified (you'll get a 403). Those are flagged in the scope reference.

Members

A membership is the join between a user and your tenant. The Membership resource never leaks the user's password or internal user ID — only their email and role slug.

{
  "id": "3f1c…-uuid",
  "user_email": "dana@acme.com",
  "role_slug": "admin",
  "created_at": "2026-06-18T14:32:18.000Z"
}

Invite or add a member

POST /v1/members/
Content-Type: application/json

{
  "email": "dana@acme.com",
  "role_slug": "admin"
}

Both fields are required. role_slug must be a built-in role (owner, admin, billing, member) or one of your custom roles.

What happens:

  • If no user exists for that email, we create one and generate a password for them — it's delivered in the invite email, never in the API response.
  • If the user already exists, they keep their own password and are simply joined to your tenant.
  • An invite email goes out on success.

Response: 201 with the new Membership. Requires the members:invite scope.

ErrorWhen
400 member_invite_invalidMissing email, unknown role_slug, or the user is already a member of this tenant.

List members

GET /v1/members/?page=1&page_size=20

Returns a paginated list of Membership objects, newest first. Requires members:read.

Change a member's role

PATCH /v1/members/{id}/
Content-Type: application/json

{ "role_slug": "billing" }

{id} is the membership UUID. The only field you can change is the role. Requires members:role_change.

You cannot demote the only owner — promote another member to owner first, or you'll get a 400.

ErrorWhen
400 member_role_invalidUnknown role_slug, or you tried to demote the last owner.
404No membership with that id in your tenant.

Remove a member

DELETE /v1/members/{id}/

Returns 204. Requires members:remove. You cannot remove the only owner (400 member_remove_invalid) — every tenant must always have at least one.

Roles

A role bundles scopes under a slug. Reading a role shows its scopes as the permissions array (each entry is a scope string):

{
  "id": "8a2d…-uuid",
  "slug": "admin",
  "name": "Administrator",
  "description": "All operational permissions except tenant deletion and owner role changes.",
  "is_system": true,
  "permissions": ["account:read", "api_keys:write", "calls:write", "…"],
  "created_at": "2026-06-18T14:32:18.000Z",
  "updated_at": "2026-06-18T14:32:18.000Z"
}

is_system: true marks a built-in role (shared across all tenants). is_system: false is one of your own custom roles. slug, id, and is_system are read-only.

Built-in roles

Four roles ship with every tenant. They're shared system roles — visible to you but not editable or deletable.

SlugNameGrants (summary)
ownerOwnerEverything. Always holds every scope, including tenant:delete. Cannot be removed from the tenant.
adminAdministratorAll operational scopes — members, roles, API keys, calls, numbers, billing, webhooks, connections, audit — except closing the tenant.
billingBillingRead-only on the account plus full billing: top up, manage payment methods, view invoices. Read-only on calls, numbers, webhooks, connections.
memberMemberMostly read-only: view account, usage, members, roles. Can place and control calls (calls:read + calls:write) but not manage the account.

The owner role automatically receives every scope, including new ones added in future releases. Exact per-role grants are listed in the scope reference.

List roles

GET /v1/roles/?page=1&page_size=20

Returns the four built-in roles plus your custom roles, paginated. Requires roles:read.

Retrieve a role

GET /v1/roles/{id}/

{id} is the role UUID. Requires roles:read. 404 if the role isn't a system role or one of yours.

Create a custom role

POST /v1/roles/
Content-Type: application/json

{
  "name": "Support agent",
  "description": "Place and review calls, read numbers.",
  "permission_codes": ["calls:read", "calls:write", "numbers:read"]
}
FieldRequiredNotes
nameyes≤120 chars. The slug is derived from this automatically.
descriptionno≤255 chars.
permission_codesyesNon-empty list of scope strings. Every code must exist (see the reference).

Response: 201 with the new Role (is_system: false). Requires roles:write. The slug is generated from name; if a role with that slug already exists in your tenant you get a 400.

ErrorWhen
400 role_invalidEmpty name, duplicate slug, empty permission_codes, or an unknown scope code (the message lists which).

Update a custom role

PATCH /v1/roles/{id}/
Content-Type: application/json

{
  "name": "Senior support",
  "permission_codes": ["calls:read", "calls:write", "numbers:read", "recordings:delete"]
}

All three fields (name, description, permission_codes) are optional — send only what you're changing. Passing permission_codes replaces the role's entire scope set, not merges it. Requires roles:write.

System roles cannot be modified — a PATCH against one returns 404 (it's not in your editable set).

Delete a custom role

DELETE /v1/roles/{id}/

Returns 204. Requires roles:delete. Two guards:

  • System roles can't be deleted (404 — not in your set).
  • A role still assigned to members can't be deleted (400 role_invalid, "Role is in use; reassign members first"). Move those members to another role, then delete.

Scope reference

Every scope your roles and API keys can carry. The wildcard * grants all of them. Gated scopes also require a verified tenant. The last four columns show which built-in roles include each scope (Owner always holds all of them).

ScopeAllowsGatedAdminBillingMember
tenant:readView own tenant
tenant:updateUpdate tenant name / metadata
tenant:deleteClose own tenant
account:readView account info, balance, quota
quota:readView own quota
quota:updateUpdate own quota (lowering only)
usage:readView usage history
ledger:readView ledger entries
ledger:writeTop up balance
api_keys:readList API keys
api_keys:writeCreate API keys
api_keys:revokeRevoke API keys
members:readList members
members:inviteInvite members
members:removeRemove members
members:role_changeChange member roles
roles:readList roles
roles:writeCreate / edit custom roles
roles:deleteDelete custom roles
audit:readView audit log
numbers:readList own phone numbers
numbers:orderOrder new phone numbers (debits balance)
numbers:configureUpdate phone number settings
numbers:releaseRelease a number back to inventory
pricing_tiers:readView configured number pricing tiers
billing:readView payments, payment methods, plans
billing:writeTop up, attach plan, manage payment methods
calls:readList and retrieve own calls / recordings
calls:writePlace outbound calls, run in-call action verbs
recordings:deleteArchive a recording
webhooks:readView webhook subscriptions and delivery log
webhooks:writeCreate, update, delete webhook subscriptions
connection:readView own connections (SIP / sub-customer scopes)
connection:writeCreate, rename, delete connections
*Wildcard — every scope above

Use these strings verbatim in permission_codes when building a custom role.

Best practices

  • Least privilege. Start members on member and grant up. For anything finer than the built-ins, build a custom role with exactly the scopes the job needs.
  • Keep owner rare. Only owner can close the tenant. Give most admins the admin role instead, and always keep at least two owners so you're never locked out.
  • Use API keys for machines, not shared logins. A member is a person; a service should authenticate with its own scoped API key. Issue a key with a narrow scope list (e.g. ["calls:write", "calls:read"]) rather than *, so a leak is contained. See Authentication.
  • Audit changes. Every invite, role change, removal, and role edit is recorded. Pull the trail from GET /v1/audit (needs audit:read).

Errors

All errors use the standard envelope:

{ "error": { "code": "role_invalid", "message": "Role is in use; reassign members first." } }

The team-and-roles codes (member_invite_invalid, member_role_invalid, member_remove_invalid, role_invalid) all arrive as 400; 404 means the member or role isn't visible to your tenant; 403 means your role or key lacks the required scope (or the scope is verification-gated and your tenant isn't verified yet). Full list: Error reference.

Next