Driftstack DRIFTSTACK docs
Docs

Team RBAC

Driftstack supports multi-user teams: one owner-account plus zero or more member-accounts joined to it via the /v1/team/* endpoints. Each member uses their own login + their own dashboard sessions. API keys remain account-scoped (shared across the team) and admin-gated.

Team directory reads (GET /v1/team/invites, /members, and /owners) require broad read or account_owner. Team membership mutations—including accepting an invite—require account_owner. The dashboard’s web session carries account_owner automatically.

Status (shipped — May 2026): end-to-end. Members can read the owner’s resources via X-Driftstack-Account: acc_<owner-uuid> on any /v1/* request; members with the admin role can also write. See “Acting on behalf of an owner” below.

Concepts

  • Owner account. The account that pays the subscription. Shows up as owner_account_id on every membership and invite.
  • Member account. A separate accounts row (own login, own email) joined to the owner via team_members. Cascade-delete on either side removes the membership.
  • Invite. A pending double-opt-in record in team_invites. Generated by the owner; consumed by the invitee. Token-hashed at rest (sha256), 7-day expiry.
  • Role. member (read-only on owner resources) or admin (full read + write).

Acting on behalf of an owner

Once a member is on a team, they can scope ANY request to the owner’s resources by passing the X-Driftstack-Account header:

GET /v1/sessions
Authorization: Bearer ds_live_<member-key>
X-Driftstack-Account: acc_<owner-uuid>

The server validates that the caller is on the team referenced by the header. The response shape is unchanged; resources are scoped to the owner instead of the caller.

Role gating:

  • Read endpoints (GET) accept both member and admin roles.
  • Write endpoints (POST / PATCH / DELETE / api-keys rotate) require admin role on the team. member role gets 403.
  • Agent-session exception. /v1/agent-sessions contains AI transcripts and live-control state, so its collection and :id surface require admin for both reads and writes.

Endpoints that honor the header :

  • /v1/sessions (GET / POST / DELETE) + /:id/{navigate,interact, wait,capture,gui-input,state}
  • /v1/agent-sessions (GET collection / POST create / :id reads and controls) — an admin member can list and operate the owner’s runs or launch the owner’s profile; each run scopes to the owner (counts against the owner’s cap, ships the owner’s per-profile DEK). member role gets 403 on this whole surface.
  • /v1/profiles (GET / POST / PATCH / DELETE)
  • /v1/api-keys (GET / POST / DELETE / :id/rotate)
  • /v1/webhooks (GET / POST / DELETE) + /:id/deliveries + /v1/webhook-deliveries/:id/replay
  • /v1/account/audit-log (GET) + /audit-log/export (GET)
  • /v1/account/email-preferences (GET / PUT — PUT admin-only)
  • /v1/usage + /v1/usage/series (GET)

Endpoints that do not honor the header (operate on the caller’s own account regardless):

  • /v1/team/* — managing your own team (members + invites).
  • /v1/account/me — always your own profile (with the team list populated so clients know which owners they can act for).
  • /v1/auth/* — authentication is per-caller.

Inverse view: the teams I am ON

Members can list the owners they’re a member of:

GET /v1/team/owners — returns { data: TeamOwner[] } where each entry has owner_account_id, owner_email (falls back to acc_<id> when unknown), owner_name (nullable), role, and membership_id. Useful for populating an “act as” picker in custom dashboards. The same data is also embedded in GET /v1/account/me under teams[].

Requires broad read or account_owner.

Invite a team member

POST /v1/team/invites — sends an email with a 7-day accept link to the invitee.

Requires account_owner.

Request:

{ "email": "[email protected]", "role": "member" }

Response (202):

{ "message": "Invite sent. The invitee can accept via the email link." }

The invitee receives an email containing a token URL. They sign up on Driftstack first if they don’t already have an account, then accept.

List pending invites

GET /v1/team/invites — pending (unaccepted, unexpired) invites for the calling owner.

Requires broad read or account_owner.

{
  "data": [
    {
      "id": "inv_…",
      "owner_account_id": "acc_…",
      "invitee_email": "[email protected]",
      "role": "member",
      "expires_at": "2026-05-15T10:00:00Z",
      "invited_by_account_id": "acc_…",
      "accepted_at": null,
      "created_at": "2026-05-08T10:00:00Z"
    }
  ]
}

Accept an invite

POST /v1/team/invites/accept — invitee calls this after signing in to their own Driftstack account.

Requires account_owner; a dashboard web session satisfies this.

Request:

{ "token": "<token-from-email>" }

Response (200):

{
  "membership": {
    "id": "mem_…",
    "owner_account_id": "acc_…",
    "member_account_id": "acc_…",
    "member_email": "[email protected]",
    "role": "member",
    "invited_at": "2026-05-08T10:00:00Z",
    "accepted_at": "2026-05-08T10:05:00Z",
    "invited_by_account_id": "acc_…"
  }
}

The accepting account’s email MUST match the invitee email — server returns 409 Conflict otherwise. This prevents accidentally accepting an invite addressed to someone else even if they share the URL.

List members

GET /v1/team/members — confirmed memberships for the calling owner.

Requires broad read or account_owner.

Remove a member

DELETE /v1/team/members/:id — owner-scoped. 404 if the membership isn’t owned by the calling account.

Removal also revokes the API keys that member minted on your account. An admin member can mint keys against your workspace, and such a key authenticates as your account — it does not stop working when their membership ends. So removal revokes every live key created by that member, in the same transaction as the membership delete.

Two consequences worth planning for:

  • If one of those keys is used by your own systems, it stops working at removal. Mint a replacement under your own account first, then remove the member. The team.member_removed audit entry lists the revoked key ids, so you can always see afterwards exactly what was invalidated.
  • Keys created before this behaviour shipped have no recorded creator and are not revoked, because there is nothing to attribute them to. Review GET /v1/api-keys when offboarding a long-standing member.

The team itself

Everything above is about MEMBERSHIP — who belongs to your team. These two are about the team as a thing with a name.

GET /v1/teams — the teams you own. Requires broad read or account_owner.

{
  "data": [
    {
      "id": "team_3f9a2c1d-...",
      "name": "Acme Research",
      "slug": null,
      "owner_account_id": "acc_...",
      "created_at": "2026-08-25T12:00:00.000Z",
      "updated_at": "2026-08-25T12:00:00.000Z"
    }
  ]
}

PATCH /v1/teams/:id — rename a team you own. Requires account_owner. Body is { "name": "..." }, 1–120 characters. Returns { "team": { ... } }.

Renaming writes a team.updated audit entry carrying both the previous and new name.

404 covers two cases deliberately: a team id that does not exist, and one that belongs to someone else. They are indistinguishable on purpose — otherwise the status code would tell you which team ids are real.

slug is always null today. The field is published because it exists on the record, but no endpoint sets one: whether team slugs become public URL components is still open. It is safe to read and will stay null until that is decided.

Teams cannot be created through the API. Your team was created for you, and there is deliberately no create endpoint. A second team under the same owner is not yet possible — memberships are currently keyed to the owning account rather than to a team, so a second team could not hold any member who is already on your first one. Creating teams arrives with that change.

SDK examples

TypeScript:

await client.team.invite('[email protected]', { role: 'admin' });
const members = await client.team.listMembers();
await client.team.acceptInvite(tokenFromEmail);
await client.team.removeMember('mem_…');

Python:

client.team.invite("[email protected]", role="admin")
members = client.team.list_members()
client.team.accept_invite(token_from_email)
client.team.remove_member("mem_…")

Go:

client.Team.Invite(ctx, &driftstack.TeamInviteRequest{
    Email: "[email protected]",
    Role:  driftstack.TeamRoleAdmin,
})
members, _ := client.Team.ListMembers(ctx)
client.Team.AcceptInvite(ctx, tokenFromEmail)
client.Team.RemoveMember(ctx, "mem_…")

Audit log

Every team mutation writes an entry to the customer audit log (/v1/account/audit-log):

Action Fires when
team.member_invited owner calls POST /v1/team/invites
team.invite_accepted invitee calls POST /v1/team/invites/accept
team.member_removed owner calls DELETE /v1/team/members/:id

Use the audit-log export to download the team history at any time:

curl -H "Authorization: Bearer $DRIFTSTACK_API_KEY" \
  "https://api.driftstack.dev/v1/account/audit-log/export?format=csv" \
  > team-history.csv

Privacy / DPA

A member is a separate Data Subject from the owner. Their account email is processed under Privacy §3.1 (Account data) on the same legal basis as any other Customer contact.