BookFlow
admin guide

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

  1. Access model
  2. Authentication
  3. Tenant management
  1. Platform analytics
  2. Feature flags
  3. Support tooling
  4. Audit log
  5. Incidents
  6. Compliance
  7. Operational runbook

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:

bash
curl 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:

ParamTypeDescription
cursorstringOpaque cursor from a previous response.
limitint (1–100)Page size, default 25.
status`active \suspended \deleted`Filter by tenant status.
plan`free \pro \business`Filter by current plan.
qstringSubstring match on name, slug, or owner email.
createdFromISO 8601 datetimeInclusive lower bound on createdAt.
createdToISO 8601 datetimeInclusive upper bound.

Example:

bash
curl "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": "[email protected]", "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": "[email protected]", "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:

ParamTypeDescription
range`24h \7d \30d \90d \1y`Default 30d.
granularity`hour \day \week \month`Default matches the range.

Example:

bash
curl "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:

  • enabled for everyone,
  • enabled for a percentage of tenants (gradual rollout),
  • enabled only for a specific allowlist of tenant ids,
  • disabled for 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:

ParamTypeDescription
cursorstringOpaque pagination cursor.
limitint (1–500)Page size, default 100.
adminIdstringFilter by the admin who took the action.
tenantIdstringFilter by affected tenant.
actionstringFilter by action name, e.g. tenant.suspend.
from, toISO 8601 datetimeTime window.

Example:

bash
curl "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": "[email protected]", "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:

  1. Detect — PagerDuty alerts fire on the metrics in §4 (queue

depth, p95 latency, error rate).

  1. Triage — check the admin metrics endpoint and the audit log

for unusual admin actions (someone might have flipped a flag).

  1. 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.

  1. 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": "..." }.

  1. Resolve — once metrics are back to normal, mark components

operational. The status page update is a separate API call.

  1. 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/:id and

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

bash
bash scripts/rotate-admin-key.sh

This script:

  1. Generates a new 32-byte secret.
  2. Sets it as the new ADMIN_API_KEY in Cloudflare Workers Secrets

for production, staging, and development environments.

  1. Keeps the old key valid for 24 hours (a fallback secret

ADMIN_API_KEY_PREVIOUS) so already-issued keys don't break instantly.

  1. 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:

bash
pnpm --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:

bash
wrangler 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.