Platform operators

Managing tenants

Platform operatorsManaging tenants

The tenants list lives at https://booking.netwit.ca/admin/tenants. It is the page you will spend the most time on as a super admin: every business on BookFlow is a tenant, and any time you need to help, investigate, suspend, or modify a tenant, you start here.

What the list shows

One row per tenant, sorted by created_at DESC (newest first). The columns are:

ColumnDescription
BusinessBusiness name + slug. A red SUSPENDED pill appears if is_suspended=1.
Planfree, pro, or enterprise. Free is the default for new signups.
UsersCount of users in tenant_members (owners + staff).
BookingsCount of bookings for this tenant, all-time.
CreatedRelative time (e.g. '3d ago').
ActionOpen the tenant detail page.

Search and filter

The page has two controls above the table:

  • Search box — does a LIKE '%query%' match against business_name, slug, and email. Case-insensitive. No special-character escaping; queries that contain SQL wildcards (% or _) are passed through as literal text in the current build, which is fine for typical use but be careful if your search term is itself a wildcard.
  • Status filter — a dropdown with three values:
    • all (default) — every tenant, suspended or not
    • active — only tenants with is_suspended = 0
    • suspended — only tenants with is_suspended = 1

Note: the API's Zod schema accepts a fourth value trial but the console UI does not expose it. trial is accepted by the schema but currently falls through to the same behavior as all — the WHERE clauses only filter for active / suspended, so a trial value returns every tenant.

Sorting

The API sorts by created_at DESC only. There is no client-side sort control and no other orderBy parameter on GET /api/admin/tenants in the current build. If you need a different sort (e.g. by bookings or revenue), the workaround is to fetch the full result set from the API and sort it in a spreadsheet.

Sort by bookings or revenue is not yet supported.
The product spec called for sort by created_at, bookings, and revenue. Only created_at is implemented. Tracking issue: ADMIN-142. Plan to add an orderBy param in a future iteration.

Pagination

20 tenants per page. The console shows Prev / Next buttons when there are more than 20 results. The API itself accepts page (min 1, default 1) and limit (min 1, max 100, default 20).

Per-row actions

Clicking a row's Open link takes you to that tenant's detail page, where you can take the actual actions:

  • View detail — see the full record, members, services, staff, customers, bookings, payments, AI activity, and the tenant-scoped audit log.
  • Suspend — flips is_suspended = 1, sets suspended_at to now, requires a suspendedReason (free-text, max 500 chars). The tenant's public booking page immediately returns 503. The owner gets an email.
  • Restore — flips is_suspended = 0, clears suspended_at. Public booking page comes back online.
  • Change plan — set plan to free, pro, or enterprise. Read the Plans and billing guide for what each plan unlocks.
  • Impersonate — opens a read-only session as the tenant owner. Every action you take while impersonating is logged to audit_log with action = 'admin.tenant.impersonate' and a reason. The console prompts for a reason before opening the impersonation session.

Bulk actions

The spec called for bulk actions (send email, export). The current console does not yet expose checkboxes for multi-select. If you need to email or export more than a handful of tenants today, use the API and a small script.

# Export all Pro tenants as JSON
curl "https://booking-api.netwit.ca/api/admin/tenants?search=pro&limit=100" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  > pro-tenants.json

API

GET/api/admin/tenants

List tenants with optional search, status filter, pagination. Returns rows plus a pagination object.

Query parameters

statusenum: all | active | suspended | trial

Default all. active filters to is_suspended = 0; suspended to is_suspended = 1; trial is not exposed in the current console.

searchstring

Free-text. Matches against business_name, slug, and email with LIKE %term%.

pageinteger

Min 1, default 1.

limitinteger

Min 1, max 100, default 20.

Sample call

curl "https://booking-api.netwit.ca/api/admin/tenants?status=active&search=salon&page=1&limit=20" \
  -H "Authorization: Bearer $ADMIN_TOKEN"

Response shape

{
  "data": [
    {
      "id": "abc123…",
      "slug": "demo-salon",
      "businessName": "Demo Salon",
      "businessType": "salon",
      "email": "owner@demosalon.com",
      "plan": "pro",
      "planStatus": "active",
      "isActive": true,
      "isSuspended": false,
      "suspendedReason": null,
      "createdAt": "2026-06-12T18:34:00Z",
      "customDomain": null,
      "userCount": 3,
      "bookingCount": 412
    }
  ],
  "pagination": { "page": 1, "limit": 20, "total": 47 }
}
What 'active' means here.
isActive reflects the soft-delete flag (is_active in D1). isSuspended is a separate axis — a tenant can be isActive: true, isSuspended: true (soft-deleted but still flagged as suspended in the audit trail) or isActive: true, isSuspended: false (the normal case). The console treats isSuspended = true as the visible state.

What this page does not do

  • It does not let you edit a tenant's profile, slug, or email. Those are tenant-owned and only the tenant owner can change them.
  • It does not let you delete a tenant. Tenants are never hard-deleted from the console. Use a D1 migration to scrub a tenant's data only after legal review.
  • It does not show revenue per tenant. Revenue is computed only at the platform level in GET /api/admin/stats.
Need a human?

Email hello@netwit.ca or call +1-604-206-8169. NetWit responds in 1 business day.