Users API
title: Users API audience: Developer difficulty: Intermediate estimated_read_time: 4 min prerequisites:
- Clerk authentication setup
- Understanding of user provisioning related_pages:
- ../api-reference/authentication.md
- ../getting-started/authentication.md
User provisioning for Clerk authentication.
Overview
The Users API handles just-in-time user provisioning when users sign in with Clerk for the first time.
Base URL: https://env.cat/api/v1/users
Authentication: Public endpoint (called by BFF)
Endpoints
Ensure User
Just-in-time user provisioning. Creates user + personal tenant on first login.
Endpoint:
POST /api/v1/users/ensure
Authorization: None (public, called by BFF)
Note: This endpoint should be secured with BFF signature verification in production (TODO).
Request Body:
{
"clerk_user_id": "user_2abc123xyz",
"email": "user@example.com",
"name": "John Doe"
}
Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
clerk_user_id | string | Yes | Clerk user ID (format: user_*) |
email | string | Yes | User's email address |
name | string | No | User's display name |
Response (200 OK) - User Already Exists:
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"tenant_id": "tenant-uuid",
"role": "owner",
"created": false
}
Response (201 Created) - New User Created:
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"tenant_id": "tenant-uuid",
"role": "owner",
"created": true
}
Response Fields:
| Field | Type | Description |
|---|---|---|
user_id | string | UUID of user (existing or newly created) |
tenant_id | string | UUID of user's personal tenant |
role | string | User's role in tenant (owner) |
created | boolean | true if user was created, false if already existed |
Errors:
| Code | Description |
|---|---|
| 400 | Missing or invalid parameters |
| 500 | Failed to create user or tenant |
Example:
curl -X POST https://env.cat/api/v1/users/ensure \
-H "Content-Type: application/json" \
-d '{
"clerk_user_id": "user_2abc123xyz",
"email": "user@example.com",
"name": "John Doe"
}'
How It Works
First Sign-In Flow
- User signs up/in with Clerk at
https://env.cat/sign-in - Clerk validates credentials and creates session
- Next.js middleware validates Clerk session
- BFF calls
/api/v1/users/ensurewith Clerk user data - Flask API checks if user exists (by
clerk_user_id) - If new user:
- Creates user record with generated username
- Creates personal tenant (named
{username}'s workspace) - Creates membership (user is owner of personal tenant)
- Returns
created: true
- If existing user:
- Updates
last_logintimestamp - Returns existing user/tenant info with
created: false
- Updates
- User redirected to dashboard
Personal Tenant
Every user automatically gets a personal tenant on first login:
Tenant properties:
- Name:
{username}'s workspace(e.g.,john's workspace) - Owner: The user (full permissions)
- Type: Personal (single-user)
- Isolation: All bundles/keys scoped to this tenant
Username generation:
- Base username from email:
user@example.com→user - Limit to 20 characters
- If username exists, append counter:
user1,user2, etc.
Idempotency
This endpoint is idempotent:
- Calling multiple times with same
clerk_user_idreturns existing user - No duplicate users created
- Safe to retry on network failures
Security Considerations
Current State (TODO)
Warning: This endpoint is currently public (no BFF signature verification).
Risk: Potential abuse (creating fake users)
Mitigation (current):
- Rate limiting recommended
- Clerk user IDs must start with
user_(validation) - Requires valid email format
Production Hardening (TODO)
Add BFF signature verification:
@bp.route('/ensure', methods=['POST'])
@verify_bff_signature # TODO: Add this decorator
def ensure_user():
# ...
Why needed:
- Ensures only BFF can call this endpoint
- Prevents direct API abuse
- Maintains security boundary
Testing
Local Development
# Test user provisioning
curl -X POST http://localhost:8080/api/v1/users/ensure \
-H "Content-Type: application/json" \
-d '{
"clerk_user_id": "user_2test123",
"email": "test@example.com",
"name": "Test User"
}'
# Response (first call):
# {"user_id":"...","tenant_id":"...","role":"owner","created":true}
# Response (second call - idempotent):
# {"user_id":"...","tenant_id":"...","role":"owner","created":false}
Production
Do not call this endpoint directly in production. It's intended to be called by the BFF layer during Clerk authentication flow.
Database Schema
User Table
CREATE TABLE users (
id TEXT PRIMARY KEY,
clerk_user_id TEXT UNIQUE NOT NULL,
username TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
auth_provider TEXT DEFAULT 'clerk',
password_hash TEXT, -- NULL for Clerk users
is_active BOOLEAN DEFAULT 1,
failed_login_attempts INTEGER DEFAULT 0,
locked_until TIMESTAMP,
last_login TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Tenant Table
CREATE TABLE tenants (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Membership Table
CREATE TABLE memberships (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
tenant_id TEXT NOT NULL REFERENCES tenants(id),
role TEXT NOT NULL, -- 'owner' or 'member'
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, tenant_id)
);
See Also
- Authentication - Clerk authentication flow
- Bundles API - Tenant-scoped bundles
- Keys API - Tenant-scoped keys
- Errors - Error codes