BookFlow — Admin Guide
This guide is for BookFlow platform operators — the people who run the BookFlow service itself, not the businesses on it. It covers tenant management, platform-wide analytics, feature flags, customer support, and the audit log. If you're a business owner on BookFlow, read the Business Guide. If you're a developer calling the BookFlow API, read the Developer API Guide.
Admin endpoints live under /api/admin/* and require the x-admin-key header. They are not documented in the public OpenAPI spec; this guide is the source of truth.
Table of contents
- 3.1 Listing tenants
- 3.2 Inspecting a tenant
- 3.2 Creating a tenant
- 3.4 Suspending a tenant
- 3.5 Deleting a tenant
- 3.6 Transferring a tenant
1. Access model
There are two levels of platform access:
- Admin key holders — BookFlow SREs and platform engineers. Hold
the ADMIN_API_KEY secret and can hit every endpoint documented here. Their access is logged with full request and response bodies to Cloudflare R2 (bf-audit-logs bucket) and surfaces in the audit log described in §7.
- Support staff — non-engineer BookFlow employees. Have a
BookFlow dashboard account with the support role. Their access is limited to read-only inspection (no write actions, no key issuance). Every page they view is audit-logged.
The ADMIN_API_KEY is a 32-byte random secret stored in Cloudflare Workers Secrets under the name ADMIN_API_KEY. It is rotated every 90 days. Rotation is handled by scripts/rotate-admin-key.sh (see §10).
2. Authentication
Admin requests are authenticated by a single header:
The key is checked on every request by the admin middleware (apps/api/src/routes/admin.ts). Missing or wrong keys return 403 forbidden. There is no rate limit on admin endpoints; instead the system hard-caps the number of unique IPs allowed to use the key in a 24-hour window, and a Cloudflare WAF rule blocks any unrecognized IP. The list of allowed IPs is kept in apps/api/src/middleware/admin-allowlist.ts and deployed with each release.
Example request:
bashcurl https://api.bookflow.app/api/admin/tenants \ -H "x-admin-key: $ADMIN_API_KEY"
Example response:
json{ "data": { "tenants": [ { "id": "tn_3k9p2m", "slug": "bramble-and-co", "name": "Bramble & Co", "plan": "pro", "status": "active", "createdAt": "2025-11-12T14:02:11Z" } ], "pagination": { "hasMore": true, "cursor": "eyJpZCI6InRuXz..." } } }
Admin endpoints always return the same envelope:
json{ "data": <payload>, "pagination": <optional> }
Errors use:
json{ "error": "forbidden" | "not_found" | "invalid_request" | "internal_error" }
with a details field for validation errors (zod's flatten() output).
3. Tenant management
A tenant in BookFlow is a single business. Every other entity (services, staff, bookings, customers, payments) is scoped to a tenant. Admin actions always operate on a specific tenant; you can never accidentally affect another tenant.
3.1 Listing tenants
Query parameters:
| Param | Type | Description | ||
|---|---|---|---|---|
cursor | string | Opaque cursor from a previous response. | ||
limit | int (1–100) | Page size, default 25. | ||
status | `active \ | suspended \ | deleted` | Filter by tenant status. |
plan | `free \ | pro \ | business` | Filter by current plan. |
q | string | Substring match on name, slug, or owner email. | ||
createdFrom | ISO 8601 datetime | Inclusive lower bound on createdAt. | ||
createdTo | ISO 8601 datetime | Inclusive upper bound. |
Example:
bashcurl "https://api.bookflow.app/api/admin/tenants?plan=pro&status=active&limit=50" \ -H "x-admin-key: $ADMIN_API_KEY"
The response is paginated cursor-style. Always use the cursor instead of an offset because admin listings can be large.
3.2 Inspecting a tenant
Returns the tenant row plus rolled-up counters: total bookings, active services, staff count, monthly revenue, current plan and trial state, and the last 5 admin actions on this tenant.
For deep inspection (every booking, every customer, every audit entry on this tenant), use:
with the same cursor and limit parameters as the platform audit log.
3.3 Creating a tenant
Body:
json{ "slug": "bramble-and-co", "name": "Bramble & Co", "ownerEmail": "owner@bramble.co", "plan": "pro", "trialDays": 14 }
The new tenant is created with the given slug and a placeholder user account; the owner email receives a one-time sign-in link. The slug must be globally unique; the API returns 409 conflict otherwise.
The action is irreversible through this endpoint; the only way to undo is to suspend or delete the tenant.
3.4 Suspending a tenant
Body:
json{ "reason": "payment_failed", "internalNote": "Stripe dispute lost" }
A suspended tenant's public booking page returns 410 gone; the dashboard sign-in is blocked; existing bookings remain in place but cannot be modified. Suspension is reversible.
3.5 Deleting a tenant
Body:
json{ "reason": "user_request", "hard": false }
This is a soft delete by default (hard: false). The tenant row is marked deleted, the slug becomes immediately available for reuse, but all data (bookings, customers, payments) is retained for 90 days in a quarantine state.
To actually erase data (GDPR right-to-be-forgotten, e.g.), pass hard: true. The action runs in a background queue; you receive a job id and can poll GET /api/admin/jobs/:jobId. Hard-deleted tenants cannot be recovered.
Warning — hard delete is irreversible. Always confirm with
legal before running. The job logs a marker in the audit log even
after the data is gone.
3.6 Transferring a tenant
Body:
json{ "toEmail": "newowner@bramble.co", "reason": "ownership_change" }
The new email gets a one-time sign-in link and is set as the owner role on the tenant. The previous owner is downgraded to admin. If the previous owner has an active Stripe Connect account, it is not transferred — the new owner must connect their own Stripe account, otherwise payments will fail.
4. Platform analytics
Returns platform-wide counters. Query parameters:
| Param | Type | Description | ||||
|---|---|---|---|---|---|---|
range | `24h \ | 7d \ | 30d \ | 90d \ | 1y` | Default 30d. |
granularity | `hour \ | day \ | week \ | month` | Default matches the range. |
Example:
bashcurl "https://api.bookflow.app/api/admin/metrics?range=7d&granularity=day" \ -H "x-admin-key: $ADMIN_API_KEY" | jq
Response (abbreviated):
json{ "data": { "tenants": { "total": 4123, "active": 3901, "new": 47 }, "bookings": { "total": 192844, "cancelled": 3211, "noShow": 2412 }, "revenueCents": 512938744, "errorsByCode": { "internal_error": 17, "rate_limited": 4881 }, "p95LatencyMs": 142, "queues": { "email": { "depth": 2, "oldestSeconds": 4 }, "sms": { "depth": 0, "oldestSeconds": null }, "webhook": { "depth": 11, "oldestSeconds": 38 } } } }
For higher-resolution data (per-minute for the last 24h) and time series, query the same endpoint with granularity=hour and a range of 24h. For a CSV export, append ?format=csv; the response is a gzipped CSV with one row per (timestamp, metric) pair.
Two custom metrics worth watching:
- `queues.email.depth` — anything above 100 means the SendGrid
integration is degraded; the runbook in §10 has the procedure.
- `p95LatencyMs` — sustained values above 500 ms usually indicate
a D1 hot-row or a queue consumer issue; check the runbook.
5. Feature flags
BookFlow uses an internal feature flag system. Flags are evaluated in the API and the dashboard at request time; a flag can be:
enabledfor everyone,enabledfor a percentage of tenants (gradual rollout),enabledonly for a specific allowlist of tenant ids,disabledfor everyone.
The flag store lives in D1 (table feature_flags) and is cached in Cloudflare KV with a 30-second TTL.
Listing flags
Returns every flag with its current evaluation rule.
Creating or updating a flag
Body:
json{ "description": "New no-show prediction model", "rollout": { "type": "percentage", "value": 10 } }
Or for an allowlist:
json{ "description": "Beta cohort for the new calendar view", "rollout": { "type": "allowlist", "tenantIds": ["tn_3k9p2m", "tn_88c1k2"] } }
Valid rollout.type values: all, none, percentage, allowlist.
The flag takes effect within 30 seconds (cache TTL). To invalidate the cache immediately, pass ?invalidate=1.
Deleting a flag
Body:
json{ "reason": "rolled out to 100%" }
The flag evaluates as false from the moment of deletion.
6. Support tooling
The BookFlow support team uses a small set of admin endpoints to look up customers, impersonate tenants (with audit), and force actions the tenant UI doesn't expose.
Looking up a customer
Returns every customer record across all tenants matching the email. Each record is tagged with the tenant id; click the id to jump to the tenant view. Useful when a customer is locked out of their account or has a booking dispute.
Impersonating a tenant
Body:
json{ "reason": "support_ticket_#4321", "ttlMinutes": 30 }
Returns a single-use impersonation URL. Open it in a private window; you are signed in as the tenant owner with full access. The session expires in ttlMinutes (max 60). Every action you take is audit-logged with the original admin's user id and the impersonation reason.
Impersonation is never silent: the tenant receives an email saying "BookFlow support is currently signed in to your account" within 5 minutes of the session starting, and again when it ends.
Force a refund
Body:
json{ "amountCents": 5000, "reason": "goodwill_after_bug" }
This bypasses the tenant's Stripe Connect account if necessary and issues a refund from the platform's own Stripe account. The amount is added to the tenant's next invoice. Use sparingly; it should only be used when the tenant's own refund flow is broken (e.g. a Stripe outage).
7. Audit log
Every admin action — read or write — is recorded in the audit log. The log is append-only; it lives in D1 for the most recent 90 days and in R2 (bf-audit-logs/yyyy/mm/dd/) for long-term storage.
Query parameters:
| Param | Type | Description |
|---|---|---|
cursor | string | Opaque pagination cursor. |
limit | int (1–500) | Page size, default 100. |
adminId | string | Filter by the admin who took the action. |
tenantId | string | Filter by affected tenant. |
action | string | Filter by action name, e.g. tenant.suspend. |
from, to | ISO 8601 datetime | Time window. |
Example:
bashcurl "https://api.bookflow.app/api/admin/audit?adminId=ad_8821&from=2026-07-01T00:00:00Z" \ -H "x-admin-key: $ADMIN_API_KEY"
Each entry contains:
json{ "id": "ev_3kj2...", "adminId": "ad_8821", "adminEmail": "alice@bookflow.app", "tenantId": "tn_3k9p2m", "action": "tenant.suspend", "requestId": "req_abc123", "ip": "203.0.113.42", "userAgent": "curl/8.6.0", "payload": { "reason": "payment_failed" }, "responseStatus": 200, "createdAt": "2026-07-25T08:14:22Z" }
The payload field may contain sensitive data (e.g. raw customer emails). Access to the audit log is itself audit-logged: the endpoint will not return data older than 90 days without an explicit ?archive=1 flag, which itself is recorded.
The audit log is exported weekly to S3 (bookflow-audit-archive) in Parquet format for long-term analysis in Athena. The export job is visible in GET /api/admin/jobs as audit_export_weekly.
8. Incidents
When something is wrong, the right order is:
- Detect — PagerDuty alerts fire on the metrics in §4 (queue
depth, p95 latency, error rate).
- Triage — check the admin metrics endpoint and the audit log
for unusual admin actions (someone might have flipped a flag).
- Mitigate — feature flag off the broken area, or scale up the
consumer if the queue is backed up. Use POST /api/admin/flags/:key/disable (a shortcut for PUT with rollout.type=none) to turn off a feature without deleting the flag.
- Communicate — post to the public status page
(<https://status.bookflow.app>) using the status-page admin tool: POST /api/admin/status with { "component": "api", "status": "degraded", "message": "..." }.
- Resolve — once metrics are back to normal, mark components
operational. The status page update is a separate API call.
- Postmortem — within 5 business days, write a postmortem in
docs/postmortems/yyyy-mm-dd-<slug>.md and link it from the incident record in the audit log via POST /api/admin/audit/:id/attach.
9. Compliance
BookFlow maintains SOC 2 Type II and ISO 27001 certifications. Admin actions that touch customer data are scoped by the following controls:
- Data residency — the admin
GET /api/admin/tenants/:idand
endpoints that return customer data do not cross regions. EU tenants' data is served by the EU Worker; the admin's location determines which regional control plane they hit.
- Encryption — admin endpoints require TLS 1.2+. The Cloudflare
WAF enforces this; older clients receive 426 upgrade required.
- Right to be forgotten — use
DELETE /api/admin/tenants/:id
with hard: true. The job is logged even after data is gone.
- Data export — for GDPR Article 15 requests, run
POST /api/admin/tenants/:id/export. The job bundles every record (tenant, services, staff, bookings, customers, payments) into a JSON file in R2 and emails a signed download link to the requester.
10. Operational runbook
A non-exhaustive list of common operational tasks and how to do them.
Rotating the admin key
bashbash scripts/rotate-admin-key.sh
This script:
- Generates a new 32-byte secret.
- Sets it as the new
ADMIN_API_KEYin Cloudflare Workers Secrets
for production, staging, and development environments.
- Keeps the old key valid for 24 hours (a fallback secret
ADMIN_API_KEY_PREVIOUS) so already-issued keys don't break instantly.
- After 24 hours, the script removes the fallback. Schedule this
in a follow-up cron job.
Restarting a queue consumer
The email, sms, and webhook queues are consumed by apps/api/src/queue-consumers/. To restart a specific consumer, redeploy just that consumer:
bashpnpm --filter @bookflow/email-consumer deploy
Each consumer is a separate Cloudflare Worker, so the redeploy is isolated from the main API.
Replaying a failed webhook
Body:
json{ "idempotencyKey": "new-key" }
The webhook is re-sent with the same payload but a new event id. The destination must accept the new id; if your downstream uses idempotency keys, the new one is the one to record.
Manual database query
D1 supports ad-hoc SQL. From the platform-engineering jump host:
bashwrangler d1 execute DB --remote \ --command "SELECT id, slug, created_at FROM tenants WHERE created_at > datetime('now', '-1 day') ORDER BY created_at DESC LIMIT 50"
The --remote flag is required to hit production. Every command runs through the audit log when invoked from the jump host; ad-hoc queries on the jump host itself are not in the audit log, so the jump host is locked down to platform engineers only.
Disaster recovery
- RPO: 1 hour (D1 has continuous backup to R2 via
wrangler d1 backup hourly).
- RTO: 30 minutes for the API, 4 hours for historical data
restore from a full R2 snapshot.
A full D1 restore procedure is in scripts/disaster-recovery.md. Do not run it without notifying the on-call manager.
For anything not covered here, escalate to the platform on-call (rotating schedule in PagerDuty) or post in #sre on Slack.