Every Causeloop client (customer) maps to one organization that owns one workspace. All provisioning happens inside a single database transaction via the onboard_client() PL/pgSQL function, which is idempotent with respect to duplicate slugs and emails.
This page is the operator runbook. If you are building a self-service onboarding flow, see the Onboarding tutorial for the end-user journey.
Prerequisites
Before you provision a client:
-
The database schema, seed data, and
onboard_client.sql functions have been loaded:
psql "$DATABASE_URL" -f db/schema.sql
psql "$DATABASE_URL" -f db/seed_reference.sql
psql "$DATABASE_URL" -f db/onboard_client.sql
-
DATABASE_URL connects as a role with sufficient privileges. onboard_client() inserts into organizations, workspaces, users, memberships, and audit_log. The schema owner or a role with INSERT on those tables is required.
-
The org slug you plan to use is unique. The function rejects duplicate slugs with an error.
Quick start — one command
SELECT onboard_client(
p_org_name => 'Acme Corp',
p_org_slug => 'acme',
p_owner_email => 'alice@acme.com'
);
This is all you need. Everything else defaults automatically.
Parameters
| Parameter | Required | Default | Notes |
|---|
p_org_name | Yes | — | Display name of the organization (shown in the UI) |
p_org_slug | Yes | — | URL-safe identifier, globally unique. Rejected if already taken. Use lowercase alphanumeric + hyphens. |
p_owner_email | Yes | — | Email address of the workspace owner. If a user with this email already exists, their record is reused. |
p_owner_name | No | Local part of email | Display name for the owner |
p_workspace_name | No | Same as p_org_name | Workspace label; slug is derived automatically |
p_plan | No | free | Billing plan: free, starter, growth, or enterprise |
p_timezone | No | UTC | IANA timezone (e.g. America/New_York). Used for display and report schedules. |
p_seats | No | 10 | Seat allotment for the organization |
What gets created
onboard_client() runs everything inside a single transaction. On success, it returns a JSON object with the new IDs:
{
"org_id": "org_01j...",
"workspace_id": "ws_01j...",
"user_id": "usr_01j...",
"membership_id": "mem_01j..."
}
Inside the transaction, the function:
- Creates an
organizations row with the given slug, name, plan, and seats
- Creates a
workspaces row linked to the organization
- Creates (or finds) a
users row for the owner email
- Creates a
memberships row linking the user to the workspace with the admin role
- Creates a default
workspace_settings row
- Writes an
audit_log entry (workspace.created) under the new workspace
Step-by-step — with full options
SELECT onboard_client(
p_org_name => 'Acme Corp',
p_org_slug => 'acme',
p_owner_email => 'alice@acme.com',
p_owner_name => 'Alice Smith',
p_workspace_name => 'Acme Main',
p_plan => 'growth',
p_timezone => 'America/New_York',
p_seats => 50
);
Run this from psql or any Postgres client connected as the schema owner:
psql "$DATABASE_URL" -c "SELECT onboard_client(
p_org_name => 'Acme Corp',
p_org_slug => 'acme',
p_owner_email => 'alice@acme.com',
p_plan => 'growth',
p_seats => 50
);"
Verification
After running onboard_client(), verify the tenant was created correctly:
-- Check the organization
SELECT id, name, slug, plan, seats_total, status
FROM organizations
WHERE slug = 'acme';
-- Check the workspace
SELECT id, name, organization_id, status
FROM workspaces
WHERE organization_id = (SELECT id FROM organizations WHERE slug = 'acme');
-- Check the owner membership
SELECT u.email, m.role, m.workspace_id
FROM memberships m
JOIN users u ON u.id = m.user_id
WHERE m.workspace_id = (
SELECT w.id FROM workspaces w
JOIN organizations o ON o.id = w.organization_id
WHERE o.slug = 'acme'
);
-- Check the audit log entry
SELECT action, actor_name, created_at
FROM audit_log
WHERE workspace_id = (
SELECT w.id FROM workspaces w
JOIN organizations o ON o.id = w.organization_id
WHERE o.slug = 'acme'
)
ORDER BY created_at DESC
LIMIT 5;
You should see:
- One
organizations row with status = 'active'
- One
workspaces row with status = 'active'
- One
memberships row with role = 'admin'
- One
audit_log entry with action = 'workspace.created'
Adding the first teammates
Option A — invite by email
curl -X POST https://api.causeloop.ai/v1/invitations \
-H "Authorization: Bearer <owner-jwt>" \
-H "Content-Type: application/json" \
-d '{"email": "bob@acme.com", "role": "analyst"}'
The invitation email is currently a stub in the development build. The invited user must accept via POST /v1/invitations/{id}/accept.
Option B — add directly
If you already have the user’s record:
INSERT INTO memberships (id, workspace_id, user_id, role, created_at)
VALUES (
'mem_' || gen_random_uuid(),
'<workspace_id>',
'<user_id>',
'analyst',
now()
);
Bulk onboarding
To provision many clients at once, wrap multiple calls in a script:
#!/usr/bin/env bash
# bulk_onboard.sh
while IFS=',' read -r name slug email plan; do
psql "$DATABASE_URL" -c "SELECT onboard_client(
p_org_name => '$name',
p_org_slug => '$slug',
p_owner_email => '$email',
p_plan => '$plan'
);"
done < clients.csv
Where clients.csv has columns: name,slug,email,plan.
Offboarding a client
Suspend (reversible)
Suspending blocks all access to the workspace while retaining all data:
SELECT offboard_client('acme', 'suspend');
This sets organizations.status = 'suspended'. API requests from suspended workspace tokens receive a 403 Forbidden. To reinstate:
UPDATE organizations SET status = 'active' WHERE slug = 'acme';
Purge (permanent, irreversible)
SELECT offboard_client('acme', 'purge');
This deletes the organizations row. The ON DELETE CASCADE constraint propagates the deletion through:
workspaces
- All tenant-scoped tables beneath the workspace (issues, patterns, memberships, connectors, audit_log, …)
offboard_client('slug', 'purge') is irreversible. All tenant data is permanently deleted. users rows are not deleted — users may belong to other organizations. Clean up user records separately if needed.Before purging, consider exporting the tenant’s data: POST /v1/gdpr/export-requests.
GDPR erasure of one person
To erase a single person’s data without removing the entire tenant, use the RTBF API:
curl -X POST https://api.causeloop.ai/v1/governance/rtbf/requests \
-H "Authorization: Bearer <admin-jwt>" \
-H "Content-Type: application/json" \
-d '{"email": "alice@acme.com", "mode": "erasure"}'
See GDPR & data governance for the full RTBF workflow.
Troubleshooting
| Error | Cause | Fix |
|---|
duplicate key value violates unique constraint "organizations_slug_key" | Slug already in use | Choose a different slug |
permission denied for table organizations | Role lacks INSERT on the table | Connect as the schema owner or grant permissions to your role |
CAUSELOOP_MASTER_KEY is not set | Application started without the master key | Set CAUSELOOP_MASTER_KEY in .env before starting |
no organization with slug "..." (from offboard_client) | Slug not found | Verify the slug matches exactly — slugs are case-sensitive |
RLS policy violation on audit_log | GUC not set before INSERT | The function calls set_config('app.current_workspace', ...) internally; ensure you are not overriding it externally |