Skip to main content

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​

SchemeMechanismUsed by
ApiKeyX-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 & PathAuthPurpose
POST /v1/signupAnonymousEntry 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/webhookAnonymous (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/subscriptionsApiKeyRequest: { 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}ApiKeyReturns all of a tenant's subscriptions.
PATCH /v1/subscriptions/{id}ApiKeyRequest: { status } (string). Updates a subscription's status directly.
GET /v1/entitlements?tenantId={id}ApiKeyPer-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}ApiKeyWhich products a tenant has active, for the product-suite switcher UI.
GET /v1/tenants/{id}/ownerApiKeyReturns 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/contactAnonymousMarketing-site contact form. { firstName, lastName, email, company, message, type }
POST /v1/feedbackAnonymous{ ... , category } — see FeedbackCategory enum in source for values.
POST /v1/supportAnonymous{ ... , 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​

ColumnTypeNotes
TenantIduniqueidentifierPK. Reused from the owning product's own existing tenant id when seeding a pre-existing tenant — never newly minted in that case.
CompanyNamenvarchar(200), required
StripeCustomerIdnvarchar(100), nullableUnique where not null
LogoBlobKeynvarchar(500), nullable
OwnerIduniqueidentifier, nullableA KeystoneUserId value, not a physical FK. Once set, never cleared — only transferred.
CreatedAtdatetime2
IsDeleted / DeletedAtbit / datetime2?Soft delete

Products​

ColumnTypeNotes
ProductIduniqueidentifierPK
ProductNamenvarchar(100), required
Slugnvarchar(100), requiredUnique. 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.
IsActivebit

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​

ColumnTypeNotes
TierIduniqueidentifierPK
ProductIduniqueidentifierFK → Products
TierNamenvarchar(100), requiredUnique 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.
IsActivebit

Same as Products: no seed data anywhere. TierPrice rows (actual prices) are owned child rows under each tier, also with no seed data.

Subscriptions​

ColumnTypeNotes
SubscriptionIduniqueidentifierPK
TenantIduniqueidentifierFK
ProductIduniqueidentifierFK
TierIduniqueidentifierFK
Statusnvarchar(20), requiredDB-level CHECK constraint, confirmed verbatim in the migration: [Status] IN ('Trial', 'Active', 'PastDue', 'Cancelled', 'Suspended') — these five are the complete, real, enforced value set.
StripeSubscriptionIdnvarchar(100), nullable
StartedAtdatetime2
TrialEndsAtdatetime2, nullable
CancelledAtdatetime2, nullable
CreatedAtdatetime2

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)​

  1. Look up the SignupAttempt by IdempotencyKey (via Stripe's client_reference_id).
  2. RegisterKeystoneAccountCommand → calls Keystone's POST /v1/accounts/provision (service-to-service, X-Keystone-ApiKey header) → sets KeystoneUserId.
  3. CreateTenantAndSubscriptionCommand → resolves Product by slug and ProductTier by (product, tier name) → fails here with a validation error if either doesn't exist → creates the Tenant (Tenant.CreateNew, a fresh id) and the Subscription.
  4. ProvisionProductAdminCommand → hardcoded to only support ProductSlug == "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.
  5. MarkSignupCompleteCommand.

Known gaps — needs live verification, not assumption​

  • Waypoint's real Product.Slug and ProductTier/TierName values 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 whether Products/ProductTiers rows 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, manual Tenant/Subscription row creation (there is no code guard against that — POST /v1/subscriptions only requires the tenant, product, and tier to already exist as rows).
  • This document reflects the solution as zipped and shared on 2026-09-30.