Command Center — API Reference
What this document is: the actual, current shape of Command Center's HTTP surface and billing/entitlement schema — read directly out of the source code (CommandCenter.API.Web/Endpoints, CommandCenter.API.Core, CommandCenter.API.Infrastructure) on 2026-09-30.
What this document is not: a design record. For rationale and history, read command-center-brd.md, command-center-prd.md, command-center-sad.md, command-center-framing.md, and command-center-database.md. Those describe intent at a point in time; this document describes what's actually built. If they disagree, this one is correct — it was generated by reading the code.
Regenerate this (re-read the real source) rather than hand-editing it from memory when it needs updating.
Auth schemes
| Scheme | Mechanism | Used by |
|---|---|---|
ApiKey | X-CommandCenter-ApiKey header, constant-time compared against a configured key. Returns RFC 7807 application/problem+json on failure (not a bare 401). | Every endpoint below that does not call AllowAnonymous() |
None (AllowAnonymous) | — | Public signup entry point, the Stripe webhook (authenticated by Stripe's own signature instead), and the Contact/Feedback/Support forms |
Endpoints
| Method & Path | Auth | Purpose |
|---|---|---|
POST /v1/signup | Anonymous | Entry point to the whole signup orchestration. Request: { idempotencyKey, email, password, companyName, firstName, lastName, phone, productSlug, tierName }. Creates a Stripe Checkout Session; the rest of the flow (account creation, tenant creation, product-admin provisioning) runs off the checkout.session.completed webhook, not this call directly. |
POST /v1/billing/webhook | Anonymous (Stripe signature) | Stripe's delivery target. Handles checkout.session.completed (drives the full post-payment sequence below) and subscription/invoice lifecycle events (customer.subscription.*, invoice.payment_*) which sync Subscription.Status. Non-2xx on any processing failure, deliberately — Stripe only redelivers on non-2xx. |
POST /v1/subscriptions | ApiKey | Request: { tenantId, productSlug, tierName, startInTrial, trialEndsAt?, stripeSubscriptionId? }. Creates one subscription row for an existing tenant. 404 if the tenant, product slug, or tier name don't resolve to real rows. 409 if that tenant already has a subscription to that product (one persistent row per Tenant+Product — use PATCH instead). |
GET /v1/subscriptions?tenantId={id} | ApiKey | Returns all of a tenant's subscriptions. |
PATCH /v1/subscriptions/{id} | ApiKey | Request: { status } (string). Updates a subscription's status directly. |
GET /v1/entitlements?tenantId={id} | ApiKey | Per-product entitlement check — covers every active product, not just ones the tenant subscribes to, so "is this tenant entitled to Waypoint" returns a real false rather than a missing entry. |
GET /v1/suite/subscriptions?tenantId={id} | ApiKey | Which products a tenant has active, for the product-suite switcher UI. |
GET /v1/tenants/{id}/owner | ApiKey | Returns the tenant's OwnerId (nullable — null with 200 is a valid answer, meaning no ownership protection is set yet; only a missing tenant is 404). |
POST /v1/contact | Anonymous | Marketing-site contact form. { firstName, lastName, email, company, message, type } |
POST /v1/feedback | Anonymous | { ... , category } — see FeedbackCategory enum in source for values. |
POST /v1/support | Anonymous | { ... , product, priority } — see SupportProduct/SupportPriority enums in source. |
What does NOT exist as an endpoint
There is no direct "create a tenant" HTTP endpoint. The only way a Tenant row gets created today is as a side effect of checkout.session.completed inside the webhook handler (CreateTenantAndSubscriptionHandler), which requires a completed Stripe Checkout Session to exist first.
The domain layer does have Tenant.Seed(tenantId, companyName) — a factory method built specifically for "a tenant that already exists in [a product] and needs a Command Center row created without minting a new TenantId" (its own doc comment names this exact scenario). As of this read, nothing in the codebase calls it — no handler, no endpoint. It's a capability designed into the domain model with no wired-up way to invoke it from outside the process. A direct SQL insert (or a new thin endpoint built around this factory) is the only way to seed a tenant outside the Stripe-driven flow today.
Data model (current, from the live EF Core migration snapshot)
Tenants
| Column | Type | Notes |
|---|---|---|
TenantId | uniqueidentifier | PK. Reused from the owning product's own existing tenant id when seeding a pre-existing tenant — never newly minted in that case. |
CompanyName | nvarchar(200), required | |
StripeCustomerId | nvarchar(100), nullable | Unique where not null |
LogoBlobKey | nvarchar(500), nullable | |
OwnerId | uniqueidentifier, nullable | A KeystoneUserId value, not a physical FK. Once set, never cleared — only transferred. |
CreatedAt | datetime2 | |
IsDeleted / DeletedAt | bit / datetime2? | Soft delete |
Products
| Column | Type | Notes |
|---|---|---|
ProductId | uniqueidentifier | PK |
ProductName | nvarchar(100), required | |
Slug | nvarchar(100), required | Unique. Confirmed real value in source: RoundTrip's slug is the lowercase string "roundtrip" (hardcoded in ProvisionProductAdminHandler). No Waypoint slug value appears anywhere in source — see "Known gaps" below. |
IsActive | bit |
There is no seed data for Products anywhere in the codebase (no HasData, no seeding script, no admin endpoint to create one). Whatever Product rows exist in a real database were inserted by hand, outside source control.
ProductTiers
| Column | Type | Notes |
|---|---|---|
TierId | uniqueidentifier | PK |
ProductId | uniqueidentifier | FK → Products |
TierName | nvarchar(100), required | Unique per (ProductId, TierName). No enumerated list of real tier names exists in code — "Starter/Standard/Professional" in other docs is descriptive convention, not a constraint. |
IsActive | bit |
Same as Products: no seed data anywhere. TierPrice rows (actual prices) are owned child rows under each tier, also with no seed data.
Subscriptions
| Column | Type | Notes |
|---|---|---|
SubscriptionId | uniqueidentifier | PK |
TenantId | uniqueidentifier | FK |
ProductId | uniqueidentifier | FK |
TierId | uniqueidentifier | FK |
Status | nvarchar(20), required | DB-level CHECK constraint, confirmed verbatim in the migration: [Status] IN ('Trial', 'Active', 'PastDue', 'Cancelled', 'Suspended') — these five are the complete, real, enforced value set. |
StripeSubscriptionId | nvarchar(100), nullable | |
StartedAt | datetime2 | |
TrialEndsAt | datetime2, nullable | |
CancelledAt | datetime2, nullable | |
CreatedAt | datetime2 |
Unique index on (TenantId, ProductId) — confirmed one persistent row per tenant+product; a resubscription updates this row, never inserts a second one.
Subscription.IsEntitled (used by GET /v1/entitlements) treats Active, Trial, and PastDue as entitled, and only Cancelled/Suspended as not — called out in the source itself as a judgment call, not a documented policy, worth confirming is actually intended.
SignupAttempts (orchestration state machine — not consumed by other products)
Tracks one signup end-to-end: IdempotencyKey (unique), Email, EncryptedPassword (cleared once Keystone registration succeeds), CompanyName, FirstName, LastName, Phone, ProductSlug, TierName, TenantId (nullable, set once the tenant's created), KeystoneUserId (nullable, set once Keystone registration succeeds), TenantUserProvisioned (bool), CreatedAt, CompletedAt (nullable).
The real signup orchestration sequence (from StripeWebhookEndpoint, checkout.session.completed)
- Look up the
SignupAttemptbyIdempotencyKey(via Stripe'sclient_reference_id). RegisterKeystoneAccountCommand→ calls Keystone'sPOST /v1/accounts/provision(service-to-service,X-Keystone-ApiKeyheader) → setsKeystoneUserId.CreateTenantAndSubscriptionCommand→ resolvesProductby slug andProductTierby (product, tier name) → fails here with a validation error if either doesn't exist → creates theTenant(Tenant.CreateNew, a fresh id) and theSubscription.ProvisionProductAdminCommand→ hardcoded to only supportProductSlug == "roundtrip"— any other slug returns"No provisioning client exists for product '{slug}' yet."This is a real, current code limitation, not a stale doc claim: Waypoint cannot complete this orchestration path today.MarkSignupCompleteCommand.
Known gaps — needs live verification, not assumption
- Waypoint's real
Product.SlugandProductTier/TierNamevalues are not in source at all. Only RoundTrip's slug ("roundtrip") is confirmed, and only because it's hardcoded into a conditional. If Waypoint rows exist, someone (or some script) inserted them directly against the database — there's no code path that created them. Confirm with a live query against Command Center's real database whetherProducts/ProductTiersrows for Waypoint exist at all before assuming a slug value, even"waypoint"by naming convention. - Because step 4 above hardcodes
"roundtrip", Waypoint going through the full/v1/signup→ webhook orchestration will currently fail at the product-admin-provisioning step regardless of whether Product/ProductTier rows exist. This only matters if something tries to run Waypoint through that endpoint; it does not block a direct, manualTenant/Subscriptionrow creation (there is no code guard against that —POST /v1/subscriptionsonly requires the tenant, product, and tier to already exist as rows). - This document reflects the solution as zipped and shared on 2026-09-30.