API Docs

Workspaces, brands, and contacts

AdminUpdated Sep 19, 2026

Workspaces, brands, and contacts

The tenancy model in Chatly — what isolates what, and why every read in the platform sits behind two locks.

Chatly's tenancy model is three nested ideas: workspace (root tenant), brand (optional sub-tenant), contact (end-user). They shape how every page query is scoped, how RLS is enforced, and how multi-customer companies model their setup.

Workspace

A workspace is the tenant root. Everything that matters — agents, contacts, conversations, channels, KB, AI configuration, integrations, billing — lives inside exactly one workspace.

Info — Workspace is the strongest isolation boundary in Chatly

Two workspaces cannot see each other's data. Period. Application-level filtering AND Postgres Row-Level Security both enforce workspace_id = $1 on every tenant-scoped table.

Parameters

Name

Type

Description

id

uuid

A bare UUID, no ws_ prefix. Generated by Postgres with gen_random_uuid(), so DB-created ids are v4, not v7 — despite the internal schema being named uuidV7Schema. Do not sort by it.

name

string

Display name (≤120 chars). Shown in the dashboard header and OAuth consent screens.

slug

string

URL-safe identifier, unique platform-wide (≤64 chars).

plan

enum

Exactly five values: free | starter | growth | business | enterprise. There is no self_hosted plan — self-hosting is a deployment shape, not a plan.

localeDefault, timezoneDefault

string

Defaults for new contacts and for business-hours arithmetic.

businessHours, businessHoursEnabled

object + bool

Per-day open/close plus holidays. Its own column rather than a settings key, because the trigger normalizer reads it on every conversation event.

branding, settings

object

Workspace-level display and configuration blobs.

suspendedAt, suspendedReason

timestamp? + string?

Platform-operator suspension.

createdAt, updatedAt

timestamp

Lifecycle.

deletedAt

timestamp?

Soft-delete. See Workspace deletion — the 30-day window is real, the automatic purge is not.

Warning — There is no region field on a workspace

Nothing in the API returns or accepts a workspace region, and no code reads one. See Multi-region below for what actually exists.

Plans and limits

What each plan actually enforces

Name

Type

Description

Seats

unlimited on every plan

Including free. The seat-limit check exists and runs on every membership write, but the configured limit is infinite on all five plans, so it never refuses. GET /v1/workspaces/me/seats returns limit: null for the same reason.

Contacts

unlimited on every plan

Contacts are deliberately not metered.

Channels

metered

free 1, starter 3, growth 10, business and enterprise unlimited. This is the one resource cap actually wired to a route — creating past it is refused.

Workflows

declared

free 1, starter 5, growth 25, business 100, enterprise unlimited.

Conversations / month

declared, not enforced

free 500, starter 10k, growth 50k, business 250k, enterprise unlimited — but enforcement sits behind a flag that defaults to OFF, so nothing is refused today.

AI spend

metered

Monthly ceilings per plan on metered LLM cost.

GET /v1/workspaces/me/seats — Bearer token

GET /v1/workspaces/me/limits — Bearer token

Warning — /limits is not about seats or contacts

It answers a different question: how many workspaces may this user own (free 1, starter 2, growth 5, business 10, enterprise unlimited). It returns owned, allowance (null = unlimited), planAtBestTier, and canCreate. For seat usage, use /seats, which returns used, limit, grandfathered, and plan.

Members + roles

A workspace has zero or more members (agents). Each member has a role and (optionally) belongs to one or more teams. See RBAC for the role matrix.

Joining a workspace happens via:

  • Direct invite (admin → email)

  • SSO + JIT provisioning (see SSO)

  • SCIM provisioning (see SCIM)

Leaving a workspace soft-deletes the membership row (deleted_at) so audit trails stay intact. Removal is DELETE /v1/memberships/{id} and requires members:remove — an owner-only permission.

Brands (multi-brand workspaces)

A brand is an optional sub-tenant inside a workspace, for businesses that serve multiple customer-facing products from one support team.

Use brands when you have:

  • Multiple consumer brands (e.g. a parent company with several product lines)

  • A primary product + a sister marketplace

  • Reseller arrangements where each downstream brand has its own customer base

The brands table is thin. What a brand row actually holds:

Parameters

Name

Type

Description

name, slug

string

Display + identifier. Slug is unique per workspace.

domain

string?

One optional hostname (≤253 chars). A single field, not a set of per-purpose domains.

branding

object

Logo URL, primary colour, support email, icons.

isDefault

boolean

Every workspace is created with one default brand, which is why channels.brand_id and conversations.brand_id can both be NOT NULL.

What a brand scopes, by other tables pointing at it:

  • Channels and conversationsbrand_id is required on both.

  • Triggers, workflows, and AI bot routes — each carries its own optional brand_id, which is how "Brand A's conversations don't get Brand B's automation" is actually expressed.

  • Help centreshelp_centers.brand_id, so each brand can front its own KB.

Warning — No per-brand email DNS

There are no SPF / DKIM / DMARC columns on a brand, and no sending-domains table. Email authentication is a property of the BYOK email provider you connect on the channel, not of the brand.

What a brand shares with sibling brands in the same workspace:

  • Agents + teams (one team can support multiple brands)

  • Billing + plan tier (one Chatly invoice per workspace)

  • Integrations (one Stripe, HubSpot, Salesforce connection — agents who switch brands still use the same CRM)

Tip — Don't use brands for environments

"Production" + "staging" + "dev" should be separate workspaces, not separate brands inside one. Brands share agents and billing; that's not what you want across environments.

Contacts

A contact is an end-user — someone who messages you, not someone who answers messages. Contacts are unified across channels: if the same person reaches you via web chat and later via WhatsApp, both threads merge under one contact record.

See Contacts for the full identity stitching story.

Conversations belong to a contact + a channel + (optionally) a brand

A conversation is the thread. It lives inside a workspace, is owned by a contact, came in on a channel, and (in multi-brand setups) belongs to a brand. See Conversations.

Workspace deletion

Deleting a workspace soft-deletes: deleted_at is set, the workspace disappears from the dashboard, and there is a 30-day grace window during which a platform operator can restore it. Past 30 days, restore is refused.

Warning — The purge after 30 days is manual, not automatic

There is no scheduled job that enumerates soft-deleted workspaces and hard-deletes them. The purge is a platform-operator action (POST /v1/admin/workspaces/{id}/purge), and it refuses to run inside the 30-day window unless explicitly forced. Until someone presses it, a soft-deleted workspace sits with deleted_at set. Plan your compliance commitments around that, not around an automatic sweep.

The purge itself is a single DELETE FROM workspaces, relying on ON DELETE CASCADE to take every dependent row with it.

Danger — Hard-delete is irreversible

Once a purge completes, there is no recovery. Export your data first if you might want it later.

Data retention

Retention is per workspace, per scope, and opt-in. A workspace with no policy configured keeps everything forever — there is no platform default and no blanket 90-day window.

GET /v1/retention/scopes — Bearer token

GET /v1/retention/policies — Bearer token

PUT /v1/retention/policies/{scope} — Bearer token

DELETE /v1/retention/policies/{scope} — Bearer token

Parameters

Name

Type

Description

scope

enum

One of conversations, messages, webhook_deliveries, cdp_events. One policy per scope per workspace.

retentionDays

integer

1 to 3650. Rows older than this are swept.

mode

enum

delete or anonymise. On messages, anonymise blanks the body and author instead of removing the row.

enabled

boolean

Master switch per policy.

The sweep runs every six hours and processes up to 5,000 rows per policy per run, so a large first sweep drains over several passes. lastRunAt and lastDeletedCount on the policy are how you confirm it is actually running.

Multi-region

Warning — Multi-region is not in production

Chatly does not currently run a multi-region deployment. There is no region field on a workspace, no per-region data pinning, and no data-residency guarantee derived from one. Treat any EU-residency requirement as something to satisfy by self-hosting in the geography you need, where you control where Postgres, Redis, and object storage live.

What does exist is a per-workspace regions table — a list of (code, label, apiUrl, realtimeUrl) entries, managed at /v1/regions. It is endpoint selection, not data residency: it changes which realtime URL the widget dials, and nothing else. Do not read it as a residency control.

Self-hosted deployments are single-region by definition; you control the geography.

Was this page helpful?