Onboard a tenant
Before any booking can flow through Ratiba, a tenant has to exist. "Tenant" means more than a row in a database — it is a Postgres schema, a Keycloak realm, a set of WhatsApp credentials, and an initial catalog of services + staff. This page walks all four through.
The schema/realm step takes ~30 seconds. Catalog onboarding takes an additional 1-2 minutes depending on how much you upload.
This page assumes Dev setup is green and pilot-preflight.sh passes.
What "onboarding" creates
Per ADR-0002, Ratiba uses schema-per-tenant Postgres isolation. The full architectural rationale — the two-pool model, the public registry structure, and the per-tenant search_path contextvar — lives in Identity and tenancy. The table below covers what the onboarding endpoint creates:
| Artifact | Where it lives | Role |
|---|---|---|
public.tenants row | Shared registry | Slug, name, vertical, locale, payment_routing flags — the single lookup for any inbound message. |
tenant_<slug> Postgres schema | Per-tenant | All business data — services, staff, appointments, payments, conversation checkpoints. Fully isolated from every other tenant. |
Keycloak realm <slug> | Keycloak server | Tenant admin OIDC + per-tenant role bindings (ADR-0001). |
public.tenant_admins row | Shared registry | Owner phone number gating admin-orchestrator commands. |
| WhatsApp credentials | public.tenants columns | whatsapp_phone_number_id, whatsapp_access_token, whatsapp_business_account_id — encrypted at rest (ADR-0008). |
| Initial catalog | tenant_<slug>.services, .staff, .staff_services, .service_relations | Populated in Step 3 via the Vision-LLM importer (ADR-0010 D5+D7). |
Atomic onboarding orchestrator
The orchestrator runs all provisioning steps with compensating actions. A failure at any step rolls back everything already created — a 5xx response never leaves leftover state.
The compensating actions are DROP SCHEMA … CASCADE (for schema + LangGraph tables) and transaction rollback (for the public.tenants and tenant_admins rows). Keycloak realm creation is the last step before commit specifically because Keycloak does not participate in a Postgres transaction — if it succeeds but the Postgres commit fails (extremely rare, but possible), the operator must manually delete the orphaned realm via DELETE /admin/realms/<slug> on the Keycloak admin API.
Step 1 — Hit the onboarding endpoint
The simplest path is POST /api/v1/admin/tenants. The endpoint requires the X-Admin-API-Key header — for local dev the key defaults to whatever you set in .env as ADMIN_API_KEY (e.g., dev-admin-key).
curl -X POST http://localhost:8010/api/v1/admin/tenants \
-H "Content-Type: application/json" \
-H "X-Admin-API-Key: dev-admin-key" \
-d '{
"name": "Spa Pilot",
"slug": "spa_pilot",
"vertical": "spa",
"owner_phone": "+254712345678",
"whatsapp_number": "+15555555555",
"locale": "en",
"timezone": "Africa/Nairobi",
"mpesa_enabled": true
}'
Slug rules: ^[a-z0-9_]+$ — lowercase alphanumerics and underscores only. Hyphens are rejected because the slug is interpolated directly into SET search_path TO tenant_<slug> (per ADR-0002 §Consequences §2). Leading digits pass the regex but will fail the Postgres SQL parser — prefer patterns like spa_pilot, salon_test_2, dental_clinic_a.
Expected response (HTTP 201):
{
"id": "01HXYZK9V8...",
"name": "Spa Pilot",
"slug": "spa_pilot",
"vertical": "spa",
"status": "trial",
"locale": "en",
"timezone": "Africa/Nairobi",
"whatsapp_number": "+15555555555",
"voice_number": null,
"mpesa_enabled": true,
"created_at": "2026-05-31T...",
"admins": []
}
You can also use the admin dashboard at http://localhost:3010/admin (Tenants tab → New Tenant form). The dashboard hits the same endpoint.
Step 2 — Verify the realm and schema
Three quick checks, one per artifact.
Keycloak realm exists. Get an admin token first, then inspect the realm:
TOKEN=$(curl -s -X POST \
"http://localhost:8281/realms/master/protocol/openid-connect/token" \
-d "client_id=admin-cli" \
-d "username=admin" \
-d "password=keycloak_dev" \
-d "grant_type=password" | jq -r '.access_token')
curl -s -H "Authorization: Bearer $TOKEN" \
http://localhost:8281/admin/realms/spa_pilot | jq '.realm, .enabled'
# "spa_pilot"
# true
Postgres schema and tables. The schema name is always tenant_<slug>:
docker compose exec postgres psql -U ratiba ratiba -c "\dn" | grep tenant_spa_pilot
# tenant_spa_pilot | ratiba
docker compose exec postgres psql -U ratiba ratiba -c "\dt tenant_spa_pilot.*"
You should see at least: services, staff, staff_services, staff_schedules, appointments, payments, customers, customer_identities, customer_sessions, plus the LangGraph checkpoint tables (checkpoints, checkpoint_writes, checkpoint_blobs, checkpoints_archive).
tenant_admins row.
docker compose exec postgres psql -U ratiba ratiba \
-c "SELECT phone_number, role, is_active \
FROM public.tenant_admins \
WHERE tenant_id = (SELECT id FROM public.tenants WHERE slug='spa_pilot');"
Expect your owner_phone with role=owner and is_active=true.
Step 3 — Onboard the catalog
The schema is ready but empty. Per ADR-0010, tenant owners onboard their catalog via a Vision-LLM-assisted upload — drag a phone photo of the existing menu, paste plain text, or both. For the full explanation of the importer, the extraction pipeline, and cross-sell relation detection, see Catalog onboarding.
Open the catalog page:
http://localhost:3010/admin/catalog
You will see an upload zone. Try one of:
- Drag-drop a phone photo of an actual spa/salon menu (any decent quality phone snap works).
- Paste plain text — the fixture
backend/tests/fixtures/sample_catalog.txtworks if you have no real menu.
The frontend POSTs to /api/v1/admin/catalog/import. The Vision LLM (Anthropic Claude Sonnet — app/services/catalog_importer.py) extracts service rows: name, price (KES), duration, optional category. Confidence is colour-coded:
| Colour | Meaning | Action required |
|---|---|---|
| Green | High confidence on name + price | Auto-acceptable |
| Yellow | Name confident but price ambiguous | Owner reviews before commit |
| Red | Price extraction failed or below safety floor (ADR-0010 D7 — "never auto-fill price") | Owner must type price manually; UI blocks commit otherwise |
Click Submit. The frontend POSTs to /api/v1/admin/catalog/commit, which writes service rows to tenant_spa_pilot.services and any detected relations to tenant_spa_pilot.service_relations.
Add staff. The catalog page has a Staff tab — add at least one staff member with a recurring weekly schedule. The booking FSM reads tenant_spa_pilot.staff_schedules to propose available slots; without a schedule, COLLECT_SLOT stalls.
For a faster alternative that skips the UI entirely and seeds both catalog and knowledge snippets programmatically, see Seed test data.
Step 4 — Verify catalog persistence
docker compose exec postgres psql -U ratiba ratiba \
-c "SELECT name, price_kes, duration_minutes \
FROM tenant_spa_pilot.services \
ORDER BY name;"
Example output:
name | price_kes | duration_minutes
-------------------+-----------+------------------
Haircut | 1500 | 30
Manicure | 1500 | 45
Pedicure | 800 | 30
Check cross-sell pairs:
docker compose exec postgres psql -U ratiba ratiba \
-c "SELECT s1.name AS primary_svc, s2.name AS related, sr.relation_type \
FROM tenant_spa_pilot.service_relations sr \
JOIN tenant_spa_pilot.services s1 ON sr.service_id = s1.id \
JOIN tenant_spa_pilot.services s2 ON sr.related_service_id = s2.id;"
If the LLM detected typical cross-sell pairs (manicure + pedicure, haircut + beard-trim) you will see them here.
What just happened
End-to-end, the atomic onboarding orchestrator:
- Inserted a
public.tenantsrow into the shared registry. - Created a
tenant_spa_pilotPostgres schema and ran the full Alembic migration set against it (includingPostgresSaver.setup()for the LangGraph checkpointer tables). - Created a Keycloak realm
spa_pilotwith the per-tenant admin client. - Inserted a
public.tenant_adminsrow, gating future admin-orchestrator commands by phone number. - (Step 3) Ran Vision-LLM extraction on your upload, let you review, then committed the catalog.
For the architectural rationale — the two-pool model, contextvar search_path propagation, the three-table public registry, and cross-channel identity — see Identity and tenancy.
Troubleshooting
Keycloak admin auth fails on the token request. The most common cause is a stale KEYCLOAK_ADMIN_REALM_PASSWORD. Default in docker-compose.yml is keycloak_dev. If Keycloak just booted, wait ~10s — its master realm can take a moment to come fully online after the container first reports (healthy).
tenant_<slug> schema is missing tables. The orchestrator successfully ran CREATE SCHEMA but failed before completing Alembic migrations. The compensating action should have dropped the half-created schema, but if a stale schema survived a non-graceful crash, drop it manually and re-POST:
docker compose exec postgres psql -U ratiba ratiba \
-c "DROP SCHEMA tenant_spa_pilot CASCADE;"
LLM extraction returns nothing or 500s. ANTHROPIC_API_KEY is missing or invalid. Check the backend log for catalog_importer.extract errors — a 401 from Anthropic means the key is bad; a timeout (60s+) means Anthropic's API is slow, retry once.
/admin/catalog returns 403 on load. Keycloak session expired or you are hitting the dashboard with a different browser profile than the one you logged into Keycloak with. Visit http://localhost:3010/admin/logout and re-login.
Slug regex rejects your input. Only ^[a-z0-9_]+$. No hyphens, no uppercase. Leading digits pass the regex but break the Postgres SQL parser for the schema identifier — use spa_pilot, not 1spa or spa-pilot.
Onboarding call hangs for 30+ seconds. Keycloak realm creation is the step with the highest latency. If the Keycloak container just restarted, its master-realm token endpoint can be slow for the first 30s after becoming (healthy). Retry the POST after the delay clears — the orchestrator's compensating actions guarantee no orphaned state from a partial failure.
What next
- Seed test data — programmatic alternative to the UI for bulk-populating knowledge snippets and catalog rows, useful for test automation.
- First booking — now that a tenant and catalog exist, send a WhatsApp message and watch the FSM book a haircut.
- Personality dials — set the agent's tone, honorifics, and cross-sell aggressiveness for this tenant.
- Catalog onboarding — the longer story behind Step 3.