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/adminApiThe API runs in Montreal, in the same Canadian region as the rest of the platform. No request or response leaves the country.
Authentication
Section titled “Authentication”Every request carries a team API key as a bearer token.
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.
The lifecycle operations
Section titled “The lifecycle operations”Read the current state
Section titled “Read the current state”GET /v1/usersGET /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.
List the sub-teams
Section titled “List the sub-teams”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.
Create
Section titled “Create”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.
Sub-team membership
Section titled “Sub-team membership”GET /v1/subteams/{subTeamId}/membersPUT /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.
Sub-team roles
Section titled “Sub-team roles”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 is | Where it is set | |
|---|---|---|
| Sub-team role | admin or member on one sub-team | This API, and the dashboard |
| Board permission | owner, editor, commenter, viewer, on one board | That board’s sharing settings. Not in this API |
| Licence level | Full or Free, what the seat costs | PATCH /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.
Deactivate
Section titled “Deactivate”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.
Seat usage
Section titled “Seat usage”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 itfor 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
409as 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.
Errors
Section titled “Errors”Every failure returns the same shape.
{ "error": { "code": "seat_limit", "message": "Adding this member would exceed the purchased seat count (50/50)." } }| Status | Code | Meaning |
|---|---|---|
| 401 | unauthenticated | No bearer token was presented. |
| 401 | invalid_credential | The key is unknown, revoked or malformed. |
| 403 | enterprise_required | The team is not on an enterprise plan. |
| 403 | enterprise_inactive | The enterprise account is paused or cancelled. |
| 404 | not_found | No such member of this team, or no such route. |
| 409 | seat_limit | The change would exceed the purchased seat count. Per-seat licences only. |
| 409 | precondition_failed | The team owner; a sub-team’s last administrator; somebody’s only sub-team; or an active sub-team they do not belong to. |
| 500 | internal | Unexpected. The change may be partly applied: re-read the member before retrying. |
What gets recorded
Section titled “What gets recorded”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.
Reading the audit trail
Section titled “Reading the audit trail”GET /v1/auditThe 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.
Reports
Section titled “Reports”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.
| Report | Answers | Columns |
|---|---|---|
people | who is active, who is not, and what they hold | uid, email, name, license, subTeamId, lastActive, status, boardsOwned, boardsCollaborating |
boards | what exists, who owns it, when it was last worked on | id, name, ownerEmail, subTeamId, createdAt, updatedAt, collaborators, externalCollaborators, linkAccess, isDeleted |
sharing | what is reachable from outside the organisation | id, name, ownerEmail, linkAccess, externalCollaborators, externalDomains, guestCount, updatedAt |
stale | what has stopped being used, and what duplicates what | id, name, ownerEmail, updatedAt, daysSinceUpdate, duplicateOf |
summary | the counts and totals the others are drawn from | counts, 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.
Sessions on a board
Section titled “Sessions on a board”GET /v1/reports/sessions?boardId={id}&since={ISO or epoch ms}&format=csvSessions 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:
| Field | Meaning |
|---|---|
startedAt, endedAt, durationMinutes | the span, from the first person arriving to the last leaving |
peakConcurrent | the most sessions connected at any one instant |
sessionCount | distinct sessions seen across the span |
capturingSessions | how many of those were recording contributions |
contributors | distinct signed-in people who changed something |
participation | contributors divided by peakConcurrent |
contributorsComplete | whether 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.
Machine-readable specification
Section titled “Machine-readable specification”The OpenAPI 3.1 description is at /docs/admin-api.openapi.yaml. It can be imported into Postman, Insomnia or an SDK generator directly.