Skip to content
Go to App

Team Administration API

Keep Bizby membership in step with the directory that owns it. The API covers the full lifecycle an enterprise needs to automate: create a member, deactivate one, add and remove sub-team membership, assign the sub-team role, change a licence level, and read seat usage.

It is available to enterprise teams and is included in the enterprise subscription. There is no additional licence, add-on or per-call charge.

Base URL

https://northamerica-northeast1-bizby-prod-ca-nane1.cloudfunctions.net/adminApi

The API runs in Montreal, in the same Canadian region as the rest of the platform. No request or response leaves the country.

Every request carries a team API key as a bearer token.

Terminal window
curl https://northamerica-northeast1-bizby-prod-ca-nane1.cloudfunctions.net/adminApi/v1/seats \
-H "Authorization: Bearer bzk_xxxxxxxx.yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"

A key is issued by the team owner from team settings. It is scoped to one team and cannot read or change anything in another. The secret half is shown once, at creation, and is stored only as a SHA-256 hash, so it cannot be recovered later. A lost key is revoked and replaced, never recovered.

A team may hold up to five active keys, which is enough to rotate without a gap and to separate environments. Revocation takes effect on the next request.

Keys cannot issue further keys. Compromise of a key exposes the team’s membership operations, not the ability to mint new credentials.

GET /v1/users
GET /v1/users/{id}

GET /v1/users returns current members and pending invitations. Pending invitations matter for reconciliation: a person who has been provisioned but has not yet signed in holds a seat, and the directory should not provision them again.

{
"members": [
{
"id": "8Kd0...",
"email": "dana@example.gc.ca",
"name": "Dana Okonkwo",
"status": "active",
"license": "Full",
"subTeamId": "delivery",
"createdAt": "2026-04-02T14:11:07.000Z"
}
],
"pendingInvites": [
{ "id": "inv_91...", "email": "sam@example.gc.ca", "subTeamIds": ["research"], "invitedAt": "2026-09-18T09:02:00.000Z" }
]
}

The member shape is deliberately narrower than the underlying user record. Starred boards, AI credit balances and billing identifiers are not exposed: provisioning does not need them.

GET /v1/subteams
{
"subTeams": [
{ "id": "kYt2...", "name": "Delivery", "description": "Service delivery", "memberCount": 12, "seatBudget": 20 },
{ "id": "9Pq7...", "name": "Research", "description": null, "memberCount": 5, "seatBudget": null }
]
}

Sub-team ids are generated, not chosen, so a sync maps its own groups to Bizby sub-teams by name and keeps the id. Archived sub-teams are left out: placing somebody into one is never what was meant.

POST /v1/users
{ "email": "sam@example.gc.ca", "subTeamIds": ["kYt2..."] }

Bizby has no password for you to set, so provisioning creates a pending invitation. The person becomes a member when they first authenticate, through SSO or otherwise. The seat is reserved from this moment either way, which is what makes seat counts reconcilable against the directory.

The call is idempotent in the way that matters: provisioning somebody who is already a member returns them with alreadyMember: true, and provisioning somebody already invited returns the existing invitation with alreadyInvited: true. Neither consumes a second seat. A reconcile job can run on a schedule without special-casing the members it has already handled.

If the team has a per-seat licence, is at its purchased seat count, and enforcement is hard, the call returns 409 seat_limit rather than quietly exceeding the contract. A team with an organization licence is never refused for seats: its seat count is the estimate the price was based on, reconciled at renewal, so provisioning always succeeds.

An unknown sub-team id returns 400. It is not ignored: an invite that resolves to no sub-team when accepted is worse than a rejected call, because nobody finds out until the person is already in.

Change the active sub-team, or the licence level

Section titled “Change the active sub-team, or the licence level”
PATCH /v1/users/{id}
{ "subTeamId": "kYt2...", "license": "Full" }

license is the licence level, which is what the seat costs.

subTeamId sets the member’s active sub-team, the one they are currently working in. It does not create or remove a membership, and the person must already be a member of that sub-team or the request is refused with 409: a pointer with no membership behind it is read by the product as no sub-team at all. Membership has its own operations, below.

Either field may be sent alone. Both are validated before anything is written, so a request carrying one acceptable change and one unacceptable one applies neither.

GET /v1/subteams/{subTeamId}/members
PUT /v1/subteams/{subTeamId}/members/{userId}
GET /v1/subteams/{subTeamId}/members/{userId}
DELETE /v1/subteams/{subTeamId}/members/{userId}

GET /v1/subteams/{subTeamId}/members lists everyone in a sub-team with their roles, which is what a reconcile needs to spot people who are in the sub-team and no longer in the group driving it.

PUT places somebody who is already a member of the enterprise into a sub-team. It never creates a user: provisioning a new person is POST /v1/users, which raises an invitation. A first placement returns 201; somebody already in the sub-team returns 200 with alreadyMember: true and their existing role is left exactly as it was, so a reconcile that repeats itself cannot quietly demote an administrator.

DELETE removes the membership and returns 200 with alreadyAbsent: true if there was nothing to remove, so a scheduled job can retry a page without tracking which rows it finished.

What removal does not do. No board is moved, reassigned or deleted, and no board permission is changed. A board the person owns keeps its owner and its sub-team assignment, and a board shared with them directly stays shared. So removing a membership is not by itself a revocation of access to the boards they can already open. For that, deactivate them with DELETE /v1/users/{id}, which transfers owned boards to the team owner, removes every sub-team membership, releases the seat and deprovisions the session.

Two refusals return 409 and are the caller’s to resolve:

  • The last administrator of a sub-team, which would leave it unadministered.
  • The person’s only sub-team, which would leave them in the enterprise belonging to none. Deactivate them instead if that is what was meant.
PATCH /v1/subteams/{subTeamId}/members/{userId}
{ "role": "admin" }

admin or member. An administrator of a sub-team can manage that sub-team’s membership from the dashboard. Sending the role somebody already holds is not an error and writes nothing, so this is safe to repeat. The last administrator cannot be demoted; designate somebody else first.

Which role gets reported. A sub-team’s administrators are the union of its administrator list and the members whose record says admin, because either grants administrative access. Every read here reports the role somebody effectively holds, so a reconcile comparing what it reads against the directory is never told that administrative access has gone when it has not.

A person can also be on that list with no membership at all, which the sub-team settings allow. DELETE on such a person returns removed: false with designationRevoked: true: nothing was deleted, but their administrative access was revoked and the change is audited. The last-administrator refusal applies there too.

Three different things, deliberately kept apart.

What it isWhere it is set
Sub-team roleadmin or member on one sub-teamThis API, and the dashboard
Board permissionowner, editor, commenter, viewer, on one boardThat board’s sharing settings. Not in this API
Licence levelFull or Free, what the seat costsPATCH /v1/users/{id}

A directory drives the first and the third. The second is a decision made per board by the people working on it, and putting it under directory control would mean a group change could silently alter who can edit a specific board.

DELETE /v1/users/{id}

Runs exactly the path the dashboard runs, because it is the same code. Pending invitations are cleared, boards and workspaces the person owned are transferred to the team owner, sub-team memberships are removed, the paid seat is released, and the session is deprovisioned so existing tokens stop working rather than lasting out their hour.

Two refusals are deliberate and return 409:

  • The team owner cannot be deactivated. Transfer ownership first.
  • The last admin of a sub-team cannot be deactivated, because it would leave that sub-team unadministered. Assign another admin first.

Both are cases where silently proceeding would lose something the organization cannot get back.

GET /v1/seats
{ "activeMembers": 42, "pendingInvites": 3, "used": 45, "purchased": 50, "enforcement": "soft", "licenseModel": "per_seat" }

licenseModel says how to read purchased. With per_seat it is the cap that enforcement applies. With organization it is an estimate: used may exceed it, and nothing is refused when it does.

Using Google Workspace as the source of truth

Section titled “Using Google Workspace as the source of truth”

Google Workspace does not push membership to applications outside its own SCIM catalogue, so the pattern is a scheduled reconcile: read the directory, read Bizby, apply the difference. A daily run is enough for most organizations; deactivation is the one case worth running more often, and can be triggered from a suspension event rather than waiting for the next pass.

# Read both sides.
directory = {u["primaryEmail"].lower() for u in workspace_users(group="bizby-users")}
bizby = get("/v1/users")
current = {m["email"].lower() for m in bizby["members"]}
invited = {i["email"].lower() for i in bizby["pendingInvites"]}
# Anyone in the directory group who is neither a member nor invited.
# Map your groups to Bizby sub-teams once, by name; ids are generated.
sub_teams = {s["name"]: s["id"] for s in get("/v1/subteams")["subTeams"]}
for email in directory - current - invited:
post("/v1/users", {"email": email, "subTeamIds": [sub_teams["Delivery"]]})
# Anyone in Bizby who is no longer in the directory group.
for member in bizby["members"]:
if member["email"].lower() not in directory:
delete(f"/v1/users/{member['id']}")

Then the same pattern for membership and role, one Workspace group per sub-team. This half is illustrative sample code rather than something Bizby ships; the endpoints it calls are the shipped ones above.

# One Workspace group per sub-team, and one per sub-team's administrators.
# bizby-delivery -> members of the Delivery sub-team
# bizby-delivery-admins -> administrators of it
for name, sub_team_id in sub_teams.items():
group = f"bizby-{name.lower()}"
want_members = {u["primaryEmail"].lower() for u in workspace_users(group=group)}
want_admins = {u["primaryEmail"].lower() for u in workspace_users(group=f"{group}-admins")}
# Directory identities are emails; Bizby ids are opaque. Map once, per run.
by_email = {m["email"].lower(): m["id"] for m in get("/v1/users")["members"]}
for email in want_members:
uid = by_email.get(email)
if not uid:
continue # not yet signed in; the invitation carries the sub-team
role = "admin" if email in want_admins else "member"
# Safe to repeat. Returns 200 alreadyMember if already placed, and
# does NOT change an existing role, which is why the PATCH follows.
put(f"/v1/subteams/{sub_team_id}/members/{uid}", {"role": role})
patch(f"/v1/subteams/{sub_team_id}/members/{uid}", {"role": role})
# Anyone in the sub-team who is no longer in its group.
for member in get(f"/v1/subteams/{sub_team_id}/members")["members"]:
if member["email"].lower() not in want_members:
delete(f"/v1/subteams/{sub_team_id}/members/{member['userId']}")

How a directory identity becomes a Bizby user id. Workspace knows people by email; Bizby ids are generated and opaque. GET /v1/users returns both, so a sync maps email -> id on each run rather than storing ids of its own. Somebody provisioned but not yet signed in has no user id at all, only a pending invitation, which is why the loop above skips them: the sub-team they will land in travels on the invitation and is applied when they first authenticate.

Three things worth building into the job rather than discovering later:

  • Treat 409 as information, not failure. A refusal to remove the team owner, a sub-team’s last administrator, or somebody’s only sub-team is the API protecting something. Surface it to a human; do not retry it.
  • Removing a membership is not removing access to boards. If the intent is that somebody should no longer reach the work, deactivate them. See the note under Sub-team membership.
  • Reconcile against the group, not the whole directory. Scope Bizby access with a Workspace group, so who gets a licence stays an identity decision made where identity lives.

Membership can also be driven from an OIDC or SAML claim at sign-in, without this API, if you would rather provision just in time. See the SSO Setup Guide. The two approaches compose: SSO decides who can get in, the API decides who holds a seat and what it costs.

Every failure returns the same shape.

{ "error": { "code": "seat_limit", "message": "Adding this member would exceed the purchased seat count (50/50)." } }
StatusCodeMeaning
401unauthenticatedNo bearer token was presented.
401invalid_credentialThe key is unknown, revoked or malformed.
403enterprise_requiredThe team is not on an enterprise plan.
403enterprise_inactiveThe enterprise account is paused or cancelled.
404not_foundNo such member of this team, or no such route.
409seat_limitThe change would exceed the purchased seat count. Per-seat licences only.
409precondition_failedThe team owner; a sub-team’s last administrator; somebody’s only sub-team; or an active sub-team they do not belong to.
500internalUnexpected. The change may be partly applied: re-read the member before retrying.

Every change writes an enterprise audit entry attributed to the key that made it, in the form admin-api:bzk_xxxxxxxx. Membership and role changes record as subteam_member_added, subteam_member_removed, subteam_admin_assigned and subteam_admin_removed, the same actions the dashboard writes, because both run the same code. A directory-driven change is therefore distinguishable from a person acting in the dashboard, which is what an auditor asks for first. Audit entries are readable in team settings and exportable for a SIEM.

Key issuance and revocation are audited too, against the person who performed them.

GET /v1/audit

The same key also reads the append-only audit trail, as a resumable cursor feed a SIEM can poll unattended. It takes since, until, category, limit, cursor and format, returns oldest first, and hands back a nextCursor to send on the next poll.

The feed deliberately runs about a minute behind real time, and says so in every response. That is what stops a record written late but stamped early from being skipped, which is the way a naive log feed loses events without anyone noticing.

The event catalogue, the paging rules, and a worked Microsoft Sentinel ingestion pattern are in Audit Log and SIEM.

GET /v1/reports/{report}
GET /v1/reports/sessions?boardId={id}

The same key reads the administrative reports, as JSON or as CSV with ?format=csv. These are the numbers behind the Insights tab, available without looking at a screen, so an organisation can hold its own figures in its own systems.

ReportAnswersColumns
peoplewho is active, who is not, and what they holduid, email, name, license, subTeamId, lastActive, status, boardsOwned, boardsCollaborating
boardswhat exists, who owns it, when it was last worked onid, name, ownerEmail, subTeamId, createdAt, updatedAt, collaborators, externalCollaborators, linkAccess, isDeleted
sharingwhat is reachable from outside the organisationid, name, ownerEmail, linkAccess, externalCollaborators, externalDomains, guestCount, updatedAt
stalewhat has stopped being used, and what duplicates whatid, name, ownerEmail, updatedAt, daysSinceUpdate, duplicateOf
summarythe counts and totals the others are drawn fromcounts, totals, generatedAt, truncatedRead. JSON only: it returns JSON even if format=csv is asked for, because it is not a table

Every row, recomputed. The Insights tab in team settings reads a stored snapshot with each table capped at 500 rows, because a settings page has to render. The API is not a page: a request here recomputes from current data and returns every row. The cost is a slower request, which is the right trade for something asked for deliberately.

One limit remains, and it is reported. The read underneath stops at 20,000 boards. When that happens the response sets truncatedRead to true, which means the figures are based on a subset: total is a floor rather than the organisation’s number, and everything derived from it is short too. An organisation at that size needs aggregation rather than a larger file. A CSV carries no such flag, so read the JSON first if you need to be sure.

GET /v1/reports/sessions?boardId={id}&since={ISO or epoch ms}&format=csv

Sessions are the exception to the shape above: the other reports describe an organisation as it stands now, this one describes what happened on one board over a window, so it takes its own parameters. boardId is required. since defaults to the period contribution records are kept, which is thirty days.

At most 200 sessions are returned, newest first. There is no paging: the only bound is the lower one, so moving since forward drops older sessions rather than revealing them, and sessions beyond the newest 200 in a window cannot currently be read at all. On a board busy enough to reach that, treat the report as the most recent 200 sessions rather than the whole period.

One row per session, written when the last participant disconnects:

FieldMeaning
startedAt, endedAt, durationMinutesthe span, from the first person arriving to the last leaving
peakConcurrentthe most sessions connected at any one instant
sessionCountdistinct sessions seen across the span
capturingSessionshow many of those were recording contributions
contributorsdistinct signed-in people who changed something
participationcontributors divided by peakConcurrent
contributorsCompletewhether contributors may be read as a count

This is contribution, not attendance, and the distinction is not cosmetic. peakConcurrent counts connected sessions, not people: somebody with two browser tabs open counts twice, so it is an upper bound on how many people were present and must not be presented as an attendance register. contributors is counted from the signed-in author of each change, so it is attributed to a person. Somebody who joined and watched appears in the first figure and not the second, which is what the ratio is for.

A participation above 1 is meaningful rather than an error. It means more people took part across the session than were ever in the room at the same moment, which is what turnover looks like.

contributorsComplete is false when contributors is a floor rather than a count, for any of three reasons: part of the session falls outside the window contribution records are kept for; the session predates contributions being tagged with the session they belong to; or capturingSessions is below sessionCount, meaning somebody in the room was not recording and a change could have gone unseen. A recorded session is not by itself evidence that the work in it was recorded.

A reconnection does not start a new session. A room that fills again within a minute continues the span it had, so a dropped connection is reported as one session rather than several short ones.

The OpenAPI 3.1 description is at /docs/admin-api.openapi.yaml. It can be imported into Postman, Insomnia or an SDK generator directly.