Skip to main content
When a new employee joins, someone usually has to click through the Box web app to create their folders, share the right resources, and add them to the correct teams. That manual work is slow, inconsistent, and easy to get wrong. Two hires in the same role can end up with different folder structures and different access - a governance and audit problem waiting to happen. This tutorial builds a middleware service that removes that manual work. When your HR system fires a hired event, the service authenticates to Box, provisions a standardized personal folder tree for the new hire, grants the right groups access at the right levels, and optionally invites the new hire to their workspace. Every new hire automatically gets an identical, governed structure.

What you are building

By the end of this tutorial, you have a working Python service that:
  • Receives an HR hired event through a webhook endpoint.
  • Resolves (or creates) the governance groups that control access, such as IT and HR.
  • Builds a standardized personal folder tree for the new hire using the Folders API.
  • Grants each governance group access at the correct folder level using the Collaboration API.
  • Optionally invites the new hire to their workspace so they have access on day one.
  • Runs idempotently, so replaying the same event never creates duplicate folders or collaborations.

Prerequisites

Before you start, make sure you have the following:
  • A Box account with administrator access. Any works, and a free also works (it authorizes Platform Apps automatically, so you can skip the Admin Console authorization step).
  • A Box application configured with Client Credentials Grant authentication, authorized in the Admin Console.
  • Python 3.11 or higher.
  • The following scopes enabled on your app:
    • Read and write all files and folders stored in Box
    • Manage groups
    • Manage users
  • The app’s App Access Level set to App + Enterprise Access (on the Configuration tab). The default App Access Only limits the service account to its own content and cannot create or manage enterprise users and groups.
  • Your Box enterprise ID (found in the Developer Console by clicking your profile icon, or in the Admin Console under Account & Billing).
Creating and managing groups and users requires admin privileges. When you use Client Credentials Grant with an enterprise_id, the app acts as the enterprise service account. This service account can only manage enterprise users and groups when the app’s App Access Level is set to App + Enterprise Access. After you enable the Manage groups and Manage users scopes and change the access level, an administrator must reauthorize the app in the Admin Console under Integrations > Platform Apps for those changes to take effect.

Step-by-step process

This solution combines four Box Platform capabilities:
ComponentPurposeAPI
GroupsResolve or create the governance groups that control accessPOST /2.0/groups, GET /2.0/groups
FoldersBuild the standardized personal folder treePOST /2.0/folders
CollaborationsGrant groups and the new hire access at the right levelsPOST /2.0/collaborations
UsersOptionally add the new hire to enterprise groups (see Scaling to production)POST /2.0/group_memberships
Box collaborations cascade. A collaboration added to a parent folder automatically grants the same access to everything inside it. This tutorial uses that behavior deliberately: broad access is granted high in the tree, and narrower access is granted only on the specific subfolders that need it.
1

Set up the development environment

  1. Open your terminal and create a new project directory:
mkdir onboarding-workspace && cd onboarding-workspace
  1. Create and activate a Python virtual environment:
python3 -m venv .venv
source .venv/bin/activate
After activation, your terminal prompt shows (.venv) at the beginning. This confirms you are working inside the virtual environment.
Every time you open a new terminal window or tab, you must re-activate the virtual environment by running source .venv/bin/activate from the project directory. If you see ModuleNotFoundError when running commands, it usually means the venv is not activated.
  1. Install the required packages:
pip install box-sdk-gen flask python-dotenv
  1. Create a .env file to store your credentials, then add the following content. Replace the placeholder values with your actual credentials from the Box Developer Console:
BOX_CLIENT_ID=your_client_id
BOX_CLIENT_SECRET=your_client_secret
BOX_ENTERPRISE_ID=your_enterprise_id
ONBOARDING_ROOT_FOLDER_ID=0
ONBOARDING_ROOT_FOLDER_ID is the folder where each new hire’s workspace is created. Leave it as 0 to create workspaces in the service account’s root, or set it to the ID of a dedicated New Hires folder.
Never commit .env files to version control. Add .env to your .gitignore.
Understanding environment variables: The .env file stores sensitive values (your actual credentials). Your Python code reads these values by referencing their names using os.getenv("VARIABLE_NAME"). For example, os.getenv("BOX_CLIENT_ID") looks up the value stored next to BOX_CLIENT_ID= in your .env file. When you copy the code in the following steps, keep the quoted variable names exactly as shown. Do not replace them with your actual credentials.
2

Authenticate the Box client

Create a file called box_client.py in your project directory and paste the following code:
import os
from dotenv import load_dotenv
from box_sdk_gen import (
    BoxClient,
    BoxCCGAuth,
    CCGConfig,
)

load_dotenv()

def get_box_client() -> BoxClient:
    config = CCGConfig(
        client_id=os.getenv("BOX_CLIENT_ID"),
        client_secret=os.getenv("BOX_CLIENT_SECRET"),
        enterprise_id=os.getenv("BOX_ENTERPRISE_ID"),
    )
    auth = BoxCCGAuth(config=config)
    return BoxClient(auth=auth)
Client Credentials Grant is recommended for server-to-server automations where no end user is present. If your organization requires JWT, swap CCGConfig/BoxCCGAuth for JWTConfig/BoxJWTAuth; the rest of the code in this tutorial is unchanged. For a comparison of both, see .
3

Define the workspace template

The heart of a governed onboarding process is a single, declarative definition of what every new hire’s workspace looks like and who can access each part of it. Create a file called config.py and paste the following code:
# Maps a logical role key to the actual Box group name.
# Edit the names to match the groups in your enterprise.
GOVERNANCE_GROUPS = {
    "IT": "IT Onboarding",
    "HR": "HR Onboarding",
    "All Employees": "All Employees",
}

# The standardized folder tree created for every new hire.
# `collaborators` grants a governance group access at that exact level.
# Because collaborations cascade, access granted on a folder also
# applies to everything nested inside it.
WORKSPACE_TEMPLATE = {
    "name": "Onboarding",
    "collaborators": [],
    "children": [
        {"name": "Personal", "collaborators": [], "children": []},
        {
            "name": "IT Setup",
            "collaborators": [{"group": "IT", "role": "editor"}],
            "children": [],
        },
        {
            "name": "HR Documents",
            "collaborators": [{"group": "HR", "role": "editor"}],
            "children": [],
        },
        {
            "name": "Policies & Templates",
            "collaborators": [{"group": "All Employees", "role": "viewer"}],
            "children": [],
        },
        {"name": "Team Resources", "collaborators": [], "children": []},
    ],
}
Keeping the structure and access rules in one template is what makes onboarding consistent and governed. To change how every future hire is set up, you edit this file - not your provisioning logic.
4

Resolve the governance groups

Before you can share folders with a group, you need its group ID. Create a file called groups.py and paste the following code. It creates each governance group if it does not exist, and looks up the existing group if it does:
from box_sdk_gen import BoxClient, BoxAPIError

from config import GOVERNANCE_GROUPS

def get_or_create_group(client: BoxClient, name: str) -> str:
    try:
        group = client.groups.create_group(
            name,
            description="Governance group managed by onboarding automation.",
        )
        print(f"Created group '{group.name}' ({group.id})")
        return group.id
    except BoxAPIError as error:
        if error.response_info.status_code != 409:
            raise
        for group in client.groups.get_groups(filter_term=name).entries:
            if group.name == name:
                print(f"Using existing group '{group.name}' ({group.id})")
                return group.id
        raise

def resolve_governance_groups(client: BoxClient) -> dict:
    """Return a map of logical group key -> Box group ID."""
    return {
        key: get_or_create_group(client, name)
        for key, name in GOVERNANCE_GROUPS.items()
    }
A group name must be unique in the enterprise, so Box returns 409 Conflict if the group already exists. Catching that status and falling back to a lookup makes the function safe to run repeatedly.
5

Build the workspace and apply collaborations

Create a file called workspace.py and paste the following code. It recursively creates each folder in the template and, at every level, shares the folder with the groups defined for that node:
from box_sdk_gen import (
    BoxClient,
    BoxAPIError,
    CreateFolderParent,
    CreateCollaborationItem,
    CreateCollaborationItemTypeField,
    CreateCollaborationAccessibleBy,
    CreateCollaborationAccessibleByTypeField,
    CreateCollaborationRole,
)

ROLE_MAP = {
    "editor": CreateCollaborationRole.EDITOR,
    "viewer": CreateCollaborationRole.VIEWER,
    "uploader": CreateCollaborationRole.UPLOADER,
    "co-owner": CreateCollaborationRole.CO_OWNER,
}

def is_already_collaborator(error: BoxAPIError) -> bool:
    # Box reports an existing collaborator differently for groups and users:
    # a group returns 409 with code "conflict", while a user can return
    # "user_already_collaborator". Match on both so re-runs are safe.
    body = error.response_info.body
    message = body.get("message", "").lower()
    return (
        body.get("code") == "user_already_collaborator"
        or "already a collaborator" in message
    )

def create_folder(client: BoxClient, name: str, parent_id: str) -> str:
    try:
        folder = client.folders.create_folder(
            name, CreateFolderParent(id=parent_id)
        )
        return folder.id
    except BoxAPIError as error:
        # 409 means a folder with this name already exists under the parent.
        # Reuse it so re-running the service never creates duplicates.
        if error.response_info.status_code != 409:
            raise
        conflicts = error.response_info.body.get("context_info", {}).get(
            "conflicts", []
        )
        return conflicts[0]["id"]

def add_group_collaboration(
    client: BoxClient, folder_id: str, group_id: str, role: str
):
    try:
        client.user_collaborations.create_collaboration(
            item=CreateCollaborationItem(
                type=CreateCollaborationItemTypeField.FOLDER, id=folder_id
            ),
            accessible_by=CreateCollaborationAccessibleBy(
                type=CreateCollaborationAccessibleByTypeField.GROUP, id=group_id
            ),
            role=ROLE_MAP[role],
        )
    except BoxAPIError as error:
        # Ignore if the group is already a collaborator on this folder.
        if not is_already_collaborator(error):
            raise

def build_workspace(
    client: BoxClient, node: dict, parent_id: str, group_ids: dict
) -> str:
    folder_id = create_folder(client, node["name"], parent_id)

    for collaborator in node.get("collaborators", []):
        group_id = group_ids[collaborator["group"]]
        add_group_collaboration(
            client, folder_id, group_id, collaborator["role"]
        )
        print(
            f"  Shared '{node['name']}' with "
            f"{collaborator['group']} ({collaborator['role']})"
        )

    for child in node.get("children", []):
        build_workspace(client, child, folder_id, group_ids)

    return folder_id
Because build_workspace walks the template recursively, the same code handles a flat structure or a deeply nested one. To change the tree, you only edit the template in config.py.
6

Orchestrate onboarding

Create a file called onboarding.py and paste the following code. This ties the pieces together: it resolves the groups, builds the workspace for a specific hire, and optionally invites the new hire to their workspace root:
import os
from dotenv import load_dotenv
from box_sdk_gen import (
    BoxClient,
    BoxAPIError,
    CreateCollaborationItem,
    CreateCollaborationItemTypeField,
    CreateCollaborationAccessibleBy,
    CreateCollaborationAccessibleByTypeField,
    CreateCollaborationRole,
)

from box_client import get_box_client
from config import WORKSPACE_TEMPLATE
from groups import resolve_governance_groups
from workspace import build_workspace, is_already_collaborator

load_dotenv()

def invite_new_hire(client: BoxClient, folder_id: str, login: str):
    """Give the new hire access to their workspace root.

    Co-owner is used when the service account owns the folder. Otherwise we
    fall back to editor, because only owners and co-owners can grant co-owner.
    """
    me = client.users.get_user_me()
    folder = client.folders.get_folder_by_id(folder_id, fields=["owned_by"])
    if folder.owned_by and folder.owned_by.id == me.id:
        role = CreateCollaborationRole.CO_OWNER
    else:
        role = CreateCollaborationRole.EDITOR

    try:
        client.user_collaborations.create_collaboration(
            item=CreateCollaborationItem(
                type=CreateCollaborationItemTypeField.FOLDER, id=folder_id
            ),
            accessible_by=CreateCollaborationAccessibleBy(
                type=CreateCollaborationAccessibleByTypeField.USER, login=login
            ),
            role=role,
        )
        print(f"Invited {login} to workspace {folder_id} as {role.value}")
    except BoxAPIError as error:
        if not is_already_collaborator(error):
            raise

def onboard_new_hire(hire: dict) -> str:
    client = get_box_client()

    group_ids = resolve_governance_groups(client)

    template = dict(WORKSPACE_TEMPLATE)
    template["name"] = f"{hire['name']} - Onboarding"

    parent_id = os.getenv("ONBOARDING_ROOT_FOLDER_ID", "0")
    workspace_id = build_workspace(client, template, parent_id, group_ids)

    if hire.get("email"):
        invite_new_hire(client, workspace_id, hire["email"])

    print(f"Workspace ready for {hire['name']} (ID: {workspace_id})")
    return workspace_id

if __name__ == "__main__":
    # Simulate a hire event for a quick local test.
    onboard_new_hire({"name": "Jordan Rivera", "email": None})
Inviting the new hire at the workspace root means they can see every folder inside it, while the group collaborations on subfolders continue to govern who else has access. invite_new_hire grants co-owner when the service account owns the workspace folder, and falls back to editor otherwise - because only an owner or co-owner can grant the co-owner role. If you point ONBOARDING_ROOT_FOLDER_ID at a folder owned by another user, the workspace folders are owned by that user, so the fallback prevents a 403. Leave email as None to provision the structure without inviting anyone yet - useful when the hire’s Box account does not exist on their start date.
7

Create the hire event listener

Create a file called app.py and paste the following code. This Flask application receives hired events from your HR system and provisions a workspace for each one:
from dotenv import load_dotenv
from flask import Flask, request, jsonify

from onboarding import onboard_new_hire

load_dotenv()
app = Flask(__name__)

@app.route("/hire", methods=["POST"])
def handle_hire_event():
    event = request.get_json()

    if event.get("event") != "employee.hired":
        return jsonify({"status": "ignored"}), 200

    employee = event["employee"]
    hire = {
        "name": employee["full_name"],
        "email": employee.get("work_email"),
    }

    workspace_id = onboard_new_hire(hire)

    return jsonify({"status": "provisioned", "workspace_id": workspace_id}), 200

if __name__ == "__main__":
    app.run(port=5000)
In production, you should verify that incoming requests genuinely originate from your HR system, typically by validating a signature or shared secret on each request. Never provision resources from an unauthenticated public endpoint.
At this point, your project directory should contain the following files:
onboarding-workspace/
├── .env
├── .venv/
├── app.py
├── box_client.py
├── config.py
├── groups.py
├── onboarding.py
└── workspace.py
8

Test the integration

You can test the full provisioning flow locally without connecting a real HR system. This step simulates the event your HR system would send when a new hire is added.
This step requires two terminal windows open at the same time. Terminal 1 runs the Flask server (which must stay running). Terminal 2 sends a test request to it.
Terminal 1 - start the server:Make sure you are in the onboarding-workspace directory and the virtual environment is activated:
cd ~/onboarding-workspace
source .venv/bin/activate
python3 app.py
You should see:
* Running on http://127.0.0.1:5000
Leave this terminal running.Terminal 2 - send a test hire event:Open a new terminal tab or window. Send a simulated HR event using curl. Replace the email with a valid address to test the invite, or remove work_email to skip it:
curl -X POST http://127.0.0.1:5000/hire \
  -H "Content-Type: application/json" \
  -d '{
    "event": "employee.hired",
    "employee": {
      "full_name": "Jordan Rivera",
      "work_email": "jordan.rivera@example.com"
    }
  }'
Check the result:Switch back to Terminal 1. You should see output confirming the groups, folders, and collaborations were created:
Created group 'IT Onboarding' (12345678)
Created group 'HR Onboarding' (12345679)
Created group 'All Employees' (12345680)
  Shared 'IT Setup' with IT (editor)
  Shared 'HR Documents' with HR (editor)
  Shared 'Policies & Templates' with All Employees (viewer)
Invited jordan.rivera@example.com to workspace 98765432
Workspace ready for Jordan Rivera (ID: 98765432)
Verify in Box:The workspace is owned by the app’s , not by your personal or admin login. The folder is created in the Service Account’s own root so it does not appear in your All Files in the Box web app unless you were added as a collaborator. This is expected. Verify the result one of these ways:
  • Query the API as the Service Account (recommended). This uses the same identity that created the workspace. List the workspace’s contents and confirm each subfolder and its collaborators:
from box_client import get_box_client

client = get_box_client()

# Replace with the workspace_id returned by the service.
workspace_id = "98765432"

for folder in client.folders.get_folder_items(workspace_id).entries:
    print(folder.type, folder.id, folder.name)
    collaborations = client.list_collaborations.get_folder_collaborations(folder.id)
    for collaboration in collaborations.entries:
        print(f"  {collaboration.accessible_by.name} - {collaboration.role.value}")
  • Open it in the Box web app. You only see the folder here if you are a collaborator on it. If you passed your own email as the new hire, open the Box web app, find Jordan Rivera - Onboarding (it appears as a shared folder), open a subfolder, click Sharing, and check the collaborator list. If no one was invited, the folder is visible only to the Service Account.
  • Preview it in Content Manager. Navigate to the Admin Console > Content > Content Manager, then select the relevant user. You are able to view the shared folder.
To make workspaces appear in a real, admin-visible location instead of the Service Account’s root, create a dedicated New Hires folder owned by an admin, add the Service Account as a co-owner collaborator on it, and set ONBOARDING_ROOT_FOLDER_ID to that folder’s ID. New workspaces are then owned by that admin and show up in their All Files.
Run it again:Send the same curl request a second time. The service completes without errors and without creating duplicate folders, groups, or collaborations. This idempotency is what makes it safe to retry failed or replayed events in production.

Troubleshooting

Your virtual environment is not activated. Run source .venv/bin/activate from the project directory before running any python3 commands. Each new terminal tab needs its own activation.
Check your .env file:
  • Verify BOX_CLIENT_ID and BOX_CLIENT_SECRET match the values in Developer Console > Configuration.
  • Confirm BOX_ENTERPRISE_ID is your enterprise ID (found in Developer Console > General Settings, or Admin Console > Account & Billing).
  • Ensure your app is authorized in the Developer Console and uses Client Credentials Grant.
The app is missing the scopes or access level required to manage groups, users, or content:
  • Set the App Access Level to App + Enterprise Access in Developer Console > Configuration. The default App Access Only cannot create or manage enterprise users and groups, which returns a 403.
  • Enable Read and write all files and folders, Manage groups, and Manage users in Developer Console > Configuration > Application Scopes.
  • After changing the access level or scopes, an administrator must reauthorize the app in the Admin Console under Integrations > Platform Apps. These changes do not take effect until the app is reauthorized.
Creating enterprise users and groups requires administrative privileges and enterprise access. Confirm the app’s App Access Level is set to App + Enterprise Access (the default App Access Only returns a 403 for these operations), that the Manage groups and Manage users scopes are enabled, and that an enterprise admin reauthorized the app after those changes.
This is expected on re-runs and is handled for you. create_folder reuses the existing folder returned in the conflict response, and is_already_collaborator detects an existing collaborator so the collaboration helpers skip it. Note that Box reports this differently for groups (409 with code conflict and the message “Group is already a collaborator”) and users (user_already_collaborator), which is why the helper checks both. If you see an unhandled 409, confirm you copied the try/except blocks and the is_already_collaborator helper exactly as shown.
If the email does not belong to an existing Box user, the collaboration is created in a pending state until they accept the invitation. To provision managed accounts for new hires as part of this flow, see .

Scaling to production

Sharing folders with groups is only half of a governance model. You can also add the new hire into groups so they automatically inherit every collaboration those groups already hold across Box. Use the Memberships API:
from box_sdk_gen import (
    BoxClient,
    CreateGroupMembershipUser,
    CreateGroupMembershipGroup,
)

def add_user_to_group(client: BoxClient, user_id: str, group_id: str):
    client.memberships.create_group_membership(
        CreateGroupMembershipUser(id=user_id),
        CreateGroupMembershipGroup(id=group_id),
    )
A common pattern is to add every new hire to an All Employees group so org-wide shares reach them on day one. See .
Adding a user who is already a member returns a 409 BoxAPIError. To keep this step safe to re-run like the rest of the service, wrap the call in the same try/except pattern used in workspace.py and ignore the conflict.
Beyond folders, you can give each new hire access to a - a curated content portal for onboarding materials, policies, and FAQs that is integrated with Box AI, so the hire can ask natural-language questions across its content. A common pattern is to maintain one shared “New Hire” Hub and add each hire (or the All Employees group) as a collaborator during provisioning.Add the following helper to workspace.py. Unlike a folder, a hub returns a generic 409 Conflict (not the already a collaborator body that is_already_collaborator matches) when the collaborator already exists, so the helper treats any 409 as an idempotent skip - keeping it safe to re-run:
from box_sdk_gen import (
    CreateHubCollaborationV2025R0Hub,
    CreateHubCollaborationV2025R0AccessibleBy,
)

def add_hub_collaborator(
    client: BoxClient, hub_id: str, login: str, role: str = "viewer"
):
    try:
        client.hub_collaborations.create_hub_collaboration_v2025_r0(
            CreateHubCollaborationV2025R0Hub(id=hub_id),
            CreateHubCollaborationV2025R0AccessibleBy(type="user", login=login),
            role,
        )
    except BoxAPIError as error:
        # A hub returns a generic 409 "conflict" (not the folder-style
        # "already a collaborator" body) when the collaborator already
        # exists, so treat any 409 here as the idempotent skip case.
        if error.response_info.status_code != 409:
            raise
Store the Hub’s ID in your .env as ONBOARDING_HUB_ID, then call the helper from onboard_new_hire when the hire has an email:
hub_id = os.getenv("ONBOARDING_HUB_ID")
if hub_id and hire.get("email"):
    add_hub_collaborator(client, hub_id, hire["email"])
To grant access at the group level instead, pass CreateHubCollaborationV2025R0AccessibleBy(type="group", id=group_id). Valid roles are viewer, editor, and co-owner.
Box Hubs must be before these calls succeed. The *_v2025_r0 SDK methods target the 2025.0 API version, which the Hubs endpoints require.The identity running the service - the Service Account when you use Client Credentials Grant - must own or co-own the Hub to manage its collaborations. If it does not, Box returns 404 Not Found with the message Authorization Failed, because it cannot see the Hub. The simplest approach is to have the Service Account create the Hub (via ) so it owns it, or add the Service Account as a co-owner of an existing Hub. To manage roles and removals, see .
Layer additional controls on the provisioned tree. Attach a instance to tag each workspace with the hire’s department and start date, and apply so onboarding documents are archived or deleted on a schedule. Metadata also makes workspaces searchable and reportable across the enterprise.
Treat the /hire endpoint as privileged. Require a shared secret or signature on every request from your HR system, restrict inbound traffic to known sources, and run the service behind HTTPS. Reject any request you cannot authenticate before it reaches your provisioning code.
HR integrations can deliver events more than once or out of order. The idempotent helpers in this tutorial already prevent duplicates, but for high volume you should also queue events, retry transient failures with backoff, and log each provisioning run so you have an audit trail of who was onboarded and when.
A complete lifecycle needs an offboarding counterpart. When your HR system fires a termination event, transfer the departing employee’s content and remove their access. See and .

Next steps

Provision users and content

Explore Box’s end-to-end patterns for provisioning users, groups, and shared folder structures.

Collaborations API reference

See the full API specification for creating collaborations.
Last modified on July 8, 2026