Skip to content

Users Service

Manage users within a company, including their experience points, assets, notes, and quickrolls. This service provides comprehensive user management for World of Darkness campaigns.

Onboarding from a provider login

To onboard a user from a provider login (Apple, Google, Discord, GitHub), use IdentityService.identify(). For users without a supported provider, create the account directly with create() below.

Usage

from vclient import users_service

users = users_service(on_behalf_of="USER_ID", company_id="COMPANY_ID")

Methods

CRUD Operations

Method Returns Description
get(user_id, *, include=None) UserDetail Retrieve a user by ID, optionally embedding quickrolls, notes, assets, characters
create(request=None, **kwargs) User Create a new user
update(user_id, request=None, **kwargs) User Update user properties
delete(user_id) None Delete a user

Embedding Child Resources

The get() method accepts an optional include parameter that embeds child resources directly in the response, eliminating the need for follow-up requests.

Valid values (UserInclude type alias): "quickrolls", "notes", "assets", "characters"

Semantic nuances

  • "assets" returns assets attached to the user, not assets the user uploaded.
  • "characters" returns only the characters the user plays (not characters they created for others).

When a value is not included in the request, the corresponding field on UserDetail is None. When requested, the field contains a list of fully populated objects — the same DTOs returned by the dedicated child endpoints.

from vclient import users_service

users = users_service(on_behalf_of="...", company_id="...")

# Embed quickrolls and characters in a single request
user = await users.get("user_id", include=["quickrolls", "characters"])
assert user.quickrolls is not None  # list[Quickroll]
assert user.characters is not None  # list[Character] — only characters the user plays
assert user.notes is None           # not requested
assert user.assets is None          # not requested

Pagination Methods

Method Returns Description
get_page(user_role=None, limit=10, offset=0) PaginatedResponse[User] Retrieve a paginated page
list_all(user_role=None) list[User] Retrieve all users
iter_all(user_role=None, limit=100) AsyncIterator[User] Iterate through all users

User Approval

Method Returns Description
get_unapproved_page(limit=10, offset=0) PaginatedResponse[User] List unapproved users (paginated)
list_all_unapproved() list[User] List all unapproved users
iter_all_unapproved(limit=100) AsyncIterator[User] Iterate through unapproved users
approve_user(user_id, role) User Approve a user and assign a role
deny_user(user_id) None Deny an unapproved user
merge(primary_user_id, secondary_user_id) User Merge unapproved user into primary
link_identity(user_id, provider=..., token=...) User Attach a verified provider identity
unlink_identity(user_id, provider=...) User Disconnect a provider identity

Statistics

Method Returns Description
get_statistics(user_id, num_top_traits=5) RollStatistics Retrieve aggregated roll stats

Experience Management

Method Returns Description
get_experience(user_id, campaign_id) CampaignExperience Retrieve XP and cool points
add_xp(user_id, campaign_id, amount) CampaignExperience Award experience points
remove_xp(user_id, campaign_id, amount) CampaignExperience Deduct experience points
add_cool_points(user_id, campaign_id, amount) CampaignExperience Award cool points

Asset Management

Method Returns Description
get_assets_page(user_id, limit=10, offset=0) PaginatedResponse[Asset] Get a page of assets
list_all_assets(user_id) list[Asset] Get all assets
iter_all_assets(user_id, limit=100) AsyncIterator[Asset] Iterate through assets
get_asset(user_id, asset_id) Asset Retrieve an asset by ID
upload_asset(user_id, filename, content) Asset Upload a new image
delete_asset(user_id, asset_id) None Delete an asset

Image uploads only

upload_asset accepts only image files: PNG, JPEG, GIF, and WEBP. Any other upload (documents, audio, video, archives, SVG, or a non-image payload mislabeled as an image) is rejected with 400 Bad Request. The stored mime_type is detected from the file's bytes; the declared content type is ignored. Newly uploaded assets always have asset_type == "image".

Avatar Management

Method Returns Description
upload_avatar(user_id, filename, content, content_type=None) User Upload a custom avatar, replacing any existing one
delete_avatar(user_id) User Remove the custom avatar

Both methods require the On-Behalf-Of header (permitted for the user themselves or an admin) and return the updated User (including the resolved avatar_url). delete_avatar responds 200 OK with the updated user body, not 204.

upload_avatar accepts PNG, JPEG, WEBP, or GIF (first frame) up to 5 MB; the server normalizes the image to a 512×512 WebP and it overrides any identity-provider-derived avatar. delete_avatar falls the avatar back to the identity-provider avatar, or null if none.

user = await client.users("user-123", company_id="company-456").upload_avatar(
    "user-123", "pic.png", image_bytes,
)
print(user.avatar_url)

user = await client.users("user-123", company_id="company-456").delete_avatar("user-123")

Notes Management

Method Returns Description
get_notes_page(user_id, limit=10, offset=0) PaginatedResponse[Note] Retrieve a paginated page
list_all_notes(user_id) list[Note] Retrieve all notes
iter_all_notes(user_id, limit=100) AsyncIterator[Note] Iterate through all notes
get_note(user_id, note_id) Note Retrieve a note by ID
create_note(user_id, request=None, **kwargs) Note Create a new note
update_note(user_id, note_id, request=None, **kwargs) Note Update note content
delete_note(user_id, note_id) None Delete a note

Quickrolls Management

Method Returns Description
get_quickrolls_page(user_id, limit=10, offset=0) PaginatedResponse[Quickroll] Retrieve a paginated page
list_all_quickrolls(user_id) list[Quickroll] Retrieve all quickrolls
iter_all_quickrolls(user_id, limit=100) AsyncIterator[Quickroll] Iterate through all quickrolls
get_quickroll(user_id, quickroll_id) Quickroll Retrieve a quickroll by ID
create_quickroll(user_id, request=None, **kwargs) Quickroll Create a new quickroll
update_quickroll(user_id, quickroll_id, request=None, **kwargs) Quickroll Update quickroll configuration
delete_quickroll(user_id, quickroll_id) None Delete a quickroll

User Roles

Role Description
ADMIN Full administrative access to company
STORYTELLER Game master role with elevated access
PLAYER Standard player with limited access
UNAPPROVED Registered but not yet approved

Examples

Create a User

Add a new user to the company.

from vclient.models import UserCreate

# Option 1: Use a model object (preferred)
request = UserCreate(
    name_first="John",
    name_last="Doe",
    username="john_doe",
    email="john@example.com",
    role="PLAYER",
)
user = await users.create(request=request)

# Option 2: Pass fields as keyword arguments
user = await users.create(
    name_first="John",
    name_last="Doe",
    username="john_doe",
    email="john@example.com",
    role="PLAYER",
)

Manage Experience Points

Award or deduct experience points for character progression.

# Get current experience
experience = await users.get_experience(user.id, campaign_id)
print(f"Current XP: {experience.xp_current}")
print(f"Total earned: {experience.xp_total}")

# Award XP
updated = await users.add_xp(user.id, campaign_id, amount=50)
print(f"New XP: {updated.xp_current}")

# Award cool points (converted to XP automatically)
updated = await users.add_cool_points(user.id, campaign_id, amount=3)

# Deduct XP for character upgrades
updated = await users.remove_xp(user.id, campaign_id, amount=20)

Create and Manage Notes

Store session notes and character information.

from vclient.models import NoteCreate, NoteUpdate

# Create a note with markdown content
note_request = NoteCreate(
    title="Session 12 Notes",
    content="## Key Events\n\n- Met the Prince\n- Discovered betrayal"
)
note = await users.create_note(user.id, note_request)

# Update note content
update = NoteUpdate(content="Updated content here...")
updated = await users.update_note(user.id, note.id, update)

# List all notes
notes = await users.list_all_notes(user.id)
for note in notes:
    print(f"{note.title}: {len(note.content)} characters")

Create Quickrolls

Set up pre-configured dice pools for common actions.

from vclient.models import QuickrollCreate

# Create a quickroll for a common dice pool
quickroll_request = QuickrollCreate(
    name="Strength + Brawl",
    description="Melee combat attack",
    trait_ids=["trait_strength_id", "trait_brawl_id"]
)
quickroll = await users.create_quickroll(user.id, quickroll_request)

# List all quickrolls
quickrolls = await users.list_all_quickrolls(user.id)
for qr in quickrolls:
    print(f"{qr.name}: {len(qr.trait_ids)} traits")

Upload and Manage Assets

Store character portraits, handouts, and other files.

# Upload a character portrait
with open("portrait.jpg", "rb") as f:
    content = f.read()

asset = await users.upload_asset(
    user.id,
    filename="john_doe_portrait.jpg",
    content=content,
)
print(f"Asset URL: {asset.public_url}")

# List all assets
all_assets = await users.list_all_assets(user.id)
for asset in all_assets:
    print(f"{asset.original_filename}: {asset.asset_type}")

# Delete an asset
await users.delete_asset(user.id, asset.id)

Get Roll Statistics

View aggregated dice roll statistics for a user.

stats = await users.get_statistics(user.id, num_top_traits=10)

print(f"Total rolls: {stats.total_rolls}")
print(f"Success rate: {stats.success_percentage:.1f}%")
print(f"Critical rate: {stats.criticals_percentage:.1f}%")
print(f"Botch rate: {stats.botch_percentage:.1f}%")

Filter Users by Role

Retrieve users filtered by their role in the company.

from vclient.constants import UserRole

# Get all storytellers
storytellers = await users.list_all(user_role=UserRole.STORYTELLER)

# Get paginated list of players
players_page = await users.get_page(user_role=UserRole.PLAYER, limit=25)
print(f"Total players: {players_page.total}")

Approve or Deny Users

Manage the user approval workflow.

from vclient.models import UserApproveDTO

# List all pending users
unapproved = await users.list_all_unapproved()
for user in unapproved:
    print(f"Pending: {user.name_first} {user.name_last} ({user.email})")

# Approve a user as a player
approved = await users.approve_user(user_id=user.id, role="PLAYER")
print(f"Approved {approved.name_first} as {approved.role}")

# Deny a user
await users.deny_user(user_id=user.id)

Merge Users

Merge an unapproved user's data into an existing primary user.

# Merge secondary user into primary user
merged_user = await users.merge(
    primary_user_id="primary_user_id",
    secondary_user_id="unapproved_user_id",
)
print(f"Merged into: {merged_user.username}")

Connect an existing user account to a verified provider credential. This is the connect-your-account flow: the user is already logged in and wants to link their Discord, Google, GitHub, or Apple account so future logins can use that provider.

The API verifies the credential with the provider before linking. Only the account owner, or a company admin acting on another user's behalf, may link an identity.

updated = await users.link_identity(
    "USER_ID",
    provider="discord",
    token=discord_access_token,
)

If the provider identity already belongs to a different user, or the target user already has a different identity from the same provider, a ConflictError is raised with code == "IDENTITY_ALREADY_LINKED".

Linking prevents duplicate accounts before they exist. Use merge() to clean up duplicates that already exist, including accounts with Apple private-relay emails that can never be auto-linked by IdentityService.identify().

Disconnect a previously linked provider from a user account. This is the inverse of link_identity(): the user (or a company admin acting on their behalf) removes a Discord, Google, GitHub, or Apple identity so it can no longer be used to log in. The returned User has the matching profile field (for example discord_profile) cleared to None.

updated = await users.unlink_identity(
    "USER_ID",
    provider="discord",
)

The API refuses to remove a user's only remaining identity, since that would leave the account unable to authenticate. In that case a ConflictError is raised with code == "LAST_IDENTITY", so disable or block the unlink control for the last connected provider. If the user has no identity from the requested provider, a NotFoundError is raised with code == "IDENTITY_NOT_LINKED".

  • Response Models - View User, UserMergeDTO, UserApproveDTO, UserIdentityLinkDTO, CampaignExperience, Asset, Note, and Quickroll model schemas
  • IdentityService - Verified provider login resolution (API-key-only, no on_behalf_of required)