Skip to main content

Command Center API — Product Requirements Document

Version: 2.0 Date: September 2026 Author: Pete Carroll, Founder — Traxs Group LLC (with Claude) Status: Draft


What changed since v1.0, and why​

v1.0 (July 2026) predates the entire subscription/entitlement schema effort — it was written when Command Center was still a pass-through contact-form service. Two real gaps, found once the actual endpoints existed to check the document against:

  1. §3.10's documented implementation for Suite Subscriptions was wrong, not just outdated. It said this endpoint "queries Stripe for active subscriptions by customer ID" — true of nothing that was ever built. Once GET /v1/suite/subscriptions was actually implemented, it reads Command Center's own Subscription table directly, the same source of truth every other endpoint in this document already assumes exists. Corrected below.
  2. GET /v1/entitlements and GET/POST/PATCH /v1/subscriptions were never documented here at all. Both exist in the BRD's scope tables (§5.2/§5.3) and are fully built, tested, and live in the codebase — but this PRD, whose whole job is documenting exactly this level of endpoint detail, was silent on both. New §3.11–§3.14 close that gap, describing what's actually implemented, not a proposal.
  3. §2.2's service-to-service endpoint list didn't mention any of the three — updated to include them.

1. Overview​

This document defines the detailed requirements for each Command Center API endpoint — what it accepts, what it returns, how it behaves, and how errors are handled.


2. Authentication Model​

The Command Center API has two authentication tiers:

2.1 Public Endpoints (Unauthenticated)​

Used by browser-facing forms on marketing and company sites. No API key required but CORS restricts to Traxs domains.

Public endpoints:

  • POST /v1/contact
  • POST /v1/support
  • POST /v1/feedback
  • GET /health

Rate limiting: 10 requests per minute per IP address.

CORS allowed origins:

[
"https://roundtrips.app",
"https://www.roundtrips.app",
"https://traxsgroup.com",
"https://www.traxsgroup.com",
"https://roundtrip-marketing.pages.dev"
]

2.2 Service-to-Service Endpoints (API Key)​

Used by Waypoint API, Relay API, Keystone, and future products. Requires X-CommandCenter-ApiKey header, enforced by a real ASP.NET Core authentication scheme (ApiKeyAuthenticationHandler) — see command-center-sad.md §4.

Service endpoints:

  • GET /v1/entitlements — live
  • GET /v1/subscriptions, POST /v1/subscriptions, PATCH /v1/subscriptions/{id} — live
  • GET /v1/suite/subscriptions — live
  • All /v1/roundtrip/* endpoints — not yet built
  • All /v1/clients/* endpoints — not yet built
  • All /v1/relay/* endpoints (future)

API key storage: CommandCenter--ApiKey in each consuming product's Key Vault. The Command Center API validates against its own stored key.

2.3 Authentication User Stories​

FA01: As a user I expect to be able to log into the system through Keystone and the system knows what subscription I am on and what products I have subscribed to.

USA01: Authentication workflow retrieves users subscription by TenantId

USA02: Authentication workflow retrieves users product entitlements by TanantId


3. Endpoint Specifications​


3.1 Health Check​

GET /health
Public. No auth required.

As documented in v1.0, this endpoint used to return an empty 200 unconditionally. It's since been rebuilt — see command-center-sad.md §7 — to run a real database connectivity check via ASP.NET Core's Health Checks framework. Response shape is now whatever that framework returns by default (a plain-text Healthy/Unhealthy/Degraded status), not the empty body shown below from the original design.

Response 200 (original v1.0 design, superseded):

{ }

3.2 Contact Form​

POST /v1/contact
Public. Unauthenticated. Rate limited.

Request body:

{
"firstName": "Pete",
"lastName": "Carroll",
"email": "pete@example.com",
"company": "Carroll HVAC",
"message": "I'd like to see a demo.",
"type": 0
}

ContactRequestType enum:

ValueNameDescription
0GeneralInquiryGeneral question
1SupportSupport request
2FeedbackProduct feedback
3DemoRequestDemo booking request

Validation:

  • firstName — required, max 100 chars
  • lastName — required, max 100 chars
  • email — required, valid email format, max 255 chars
  • message — required, max 5000 chars
  • company — optional, max 200 chars
  • type — required, valid enum value

On success:

  1. Send confirmation email to submitter via SendGrid
    • From: noreply@roundtrips.app
    • From name: RoundTrip by Traxs Group LLC
    • Subject: We received your message — RoundTrip by Traxs Group LLC
  2. Send notification email to hello@traxsgroup.com
    • Subject: [{type}] New contact from {firstName} {lastName}
    • Body: full submission details

Response 200:

{
"success": true,
"errorMessage": null
}

Response 422 (validation failure):

{
"success": false,
"errorMessage": "Email address is required."
}

Note, added in this revision: this validation is now actually enforced. A real gap was found and fixed during this project: the FluentValidation validators for Contact/Support/Feedback existed from the start but were never invoked by anything — FastEndpoints' own validator auto-discovery targets the Request DTO, and these validators were (correctly, for architectural reasons) written against the Command instead. A ValidationBehavior Mediator pipeline behavior now actually runs them. Before this fix, invalid input was silently accepted.

Response 500 (SendGrid failure):

{
"success": false,
"errorMessage": "Failed to send email."
}

3.3 Support Request​

POST /v1/support
Public. Unauthenticated. Rate limited.

Request body:

{
"firstName": "Mike",
"lastName": "Callahan",
"email": "mike@hvacpros.com",
"company": "HVAC Pros LLC",
"subject": "Invoice not sending",
"message": "When I click Send Invoice nothing happens.",
"product": "RoundTrip",
"priority": "Normal"
}

Validation: Same as contact form plus:

  • subject — required, max 200 chars
  • product — required, one of: RoundTrip, Waypoint, Relay, General
  • priority — optional, one of: Low, Normal, High, Critical

On success:

  1. Send confirmation email to submitter
  2. Send notification email to support@roundtrips.app
  3. Future: Create Crisp ticket via Crisp API

Response: Same pattern as /v1/contact


3.4 In-App Feedback​

POST /v1/feedback
Public. Rate limited. Called from within Traxs product apps.

Request body:

{
"email": "mike@hvacpros.com",
"name": "Mike Callahan",
"product": "RoundTrip",
"category": "FeatureRequest",
"message": "It would be great if I could schedule recurring jobs.",
"currentPage": "/tickets"
}

On success:

  1. Send notification to hello@traxsgroup.com
  2. Future: Create Linear issue via Linear API

Response: Same pattern as /v1/contact


3.5 RoundTrip Revenue Summary​

GET /v1/roundtrip/revenue-summary
Service-to-service. Requires X-CommandCenter-ApiKey header. Not yet built — see command-center-sad.md §9/§10.

Query parameters:

ParameterTypeRequiredDescription
tenantIdGuidYesThe Traxs tenant ID
periodstringNomtd (default), ytd, last30, last90

How it works:

  1. Command Center API receives request from Waypoint
  2. Validates API key
  3. Calls RoundTrip API: GET /v1/internal/revenue-summary?tenantId={id}&period={period}
  4. RoundTrip API validates that the tenantId exists and has an active subscription
  5. Returns revenue data
  6. Command Center API translates to its own DTO and returns to Waypoint

Response 200:

{
"tenantId": "ad05b136-e770-4650-b563-75b7dad17234",
"period": "mtd",
"totalRevenue": 12480.00,
"currency": "USD",
"paidInvoiceCount": 14,
"fromDate": "2026-07-01",
"toDate": "2026-07-07",
"asOf": "2026-07-07T20:30:00Z"
}

Response 404: Tenant not found or not subscribed to RoundTrip
Response 503: RoundTrip API unavailable — Waypoint handles gracefully


3.6 RoundTrip Open Tickets​

GET /v1/roundtrip/tickets/open
Service-to-service. Not yet built.

Query parameters:

ParameterTypeRequired
tenantIdGuidYes

Response 200:

{
"tenantId": "...",
"totalOpen": 8,
"unassigned": 3,
"inProgress": 2,
"assigned": 3,
"estimatedValue": 3600.00,
"currency": "USD",
"asOf": "2026-07-07T20:30:00Z"
}

3.7 RoundTrip Recent Invoices​

GET /v1/roundtrip/invoices/recent
Service-to-service. Not yet built.

Query parameters:

ParameterTypeRequiredDefault
tenantIdGuidYes—
countintNo5

Response 200:

{
"tenantId": "...",
"invoices": [
{
"invoiceNumber": "INV-2026-0042",
"clientName": "Briggs Property Management",
"amount": 850.00,
"currency": "USD",
"status": "Paid",
"sentAt": "2026-07-05T14:30:00Z"
}
],
"asOf": "2026-07-07T20:30:00Z"
}

3.8 RoundTrip Team​

GET /v1/roundtrip/team
Service-to-service. Not yet built.

Query parameters:

ParameterTypeRequired
tenantIdGuidYes

Response 200:

{
"tenantId": "...",
"members": [
{
"userId": "...",
"firstName": "Derek",
"lastName": "Osei",
"role": "Technician",
"isActive": true
}
],
"asOf": "2026-07-07T20:30:00Z"
}

3.9 RoundTrip AR Aging​

GET /v1/roundtrip/ar/aging
Service-to-service. Not yet built.

Response 200:

{
"tenantId": "...",
"totalOutstanding": 4200.00,
"currency": "USD",
"buckets": {
"current": { "amount": 1800.00, "count": 3 },
"thirtyDays": { "amount": 1200.00, "count": 2 },
"sixtyDays": { "amount": 800.00, "count": 1 },
"ninetyPlus": { "amount": 400.00, "count": 1 }
},
"oldestInvoiceDays": 87,
"asOf": "2026-07-07T20:30:00Z"
}

3.10 Suite Subscriptions​

GET /v1/suite/subscriptions
Service-to-service. Called by product frontends to determine suite switcher display. Live.

Query parameters:

ParameterTypeRequired
tenantIdGuidYes

Response 200:

{
"tenantId": "...",
"subscriptions": {
"roundtrip": {
"active": true,
"plan": "Standard",
"url": "https://app.roundtrips.app"
},
"waypoint": {
"active": true,
"plan": "Starter",
"url": "https://traxswaypoint.app"
},
"relay": {
"active": false,
"plan": null,
"url": "https://traxsrelay.app"
}
}
}

Response 404: Tenant not found.

Implementation (corrected — this was wrong in v1.0): Reads Command Center's own Subscription table directly — the same source of truth GET /v1/entitlements and GET /v1/subscriptions use, populated by each product's own Stripe webhook handler via POST/PATCH /v1/subscriptions. This endpoint does not call Stripe. The original v1.0 text ("Queries Stripe for active subscriptions by customer ID... Stripe customer is shared across products") described a design that predated the whole subscription schema and was never actually built.

active uses the same entitlement semantics as GET /v1/entitlements — Subscription.Status of Active, Trial, or PastDue all count, so a customer mid-trial or in a brief past-due grace period still sees the product in their suite switcher. plan is the subscription's tier name, null when not subscribed. url is populated regardless of active status — it's static per-product app configuration (appsettings.json's SuiteUrls section), not tied to subscription state. A product with no configured URL returns null for url rather than erroring.


3.11 Entitlements​

GET /v1/entitlements
Service-to-service. Per BRD §5.3 — the endpoint Keystone calls to resolve a tenant's per-product entitlement. Live, replacing the OpenIddict spike's hardcoded stub.

Query parameters:

ParameterTypeRequired
tenantIdGuidYes

Response 200:

{
"tenantId": "ad05b136-e770-4650-b563-75b7dad17234",
"entitlements": [
{ "productSlug": "roundtrip", "isEntitled": true, "tier": "Standard", "effectivePrice": 79.00 },
{ "productSlug": "waypoint", "isEntitled": false, "tier": null, "effectivePrice": null },
{ "productSlug": "relay", "isEntitled": false, "tier": null, "effectivePrice": null }
]
}

Every currently-active product gets an entry, not just ones the tenant has subscribed to — a caller asking "is this tenant entitled to Waypoint" gets a real false, not a missing key.

isEntitled is true when Subscription.Status is Active, Trial, or PastDue. This is a deliberate judgment call — nothing in the original requirements specifies which statuses count as entitled — matching how Stripe's own past_due typically still grants access during a dunning/grace period. Cancelled and Suspended are not entitled.

effectivePrice is the subscribed tier's current rate, unless an active PriceCommitment line exists for that exact (product, tier) — in which case the locked rate wins. This is the concrete mechanism behind the Founders Program's rate lock.

Response 404: Tenant not found.

Error handling note: if a subscription references a tier that no longer exists (a data integrity problem, not a normal case), that single product is reported as not entitled rather than failing the whole response — one bad row shouldn't take down entitlement checks for every other product the tenant is correctly subscribed to. If a tier itself has no current price row (also a data integrity problem, and one that shouldn't be reachable through normal use of the system), the request fails with a 422 rather than silently reporting incorrect entitlement — this is deliberately loud rather than quiet, since masking it could hide a bug from whoever's debugging why a paying customer lost access.


3.12 List Subscriptions​

GET /v1/subscriptions
Service-to-service. Per BRD §5.2. Live.

Query parameters:

ParameterTypeRequired
tenantIdGuidYes

Response 200:

{
"tenantId": "...",
"subscriptions": [
{
"subscriptionId": "...",
"productSlug": "roundtrip",
"tier": "Standard",
"status": "Active",
"startedAt": "2026-06-01T00:00:00Z",
"trialEndsAt": null,
"cancelledAt": null
}
]
}

Product slug and tier name are resolved from Command Center's own internal IDs, not returned as raw Guids. If a subscription references a product or tier that's since been removed, that entry reports "(unknown product)"/"(unknown tier)" rather than causing the whole list to fail.

Response 404: Tenant not found.


3.13 Create Subscription​

POST /v1/subscriptions
Service-to-service. Per BRD §5.2 — called by each product's own Stripe webhook handler when a tenant subscribes. Live.

Request body:

{
"tenantId": "...",
"productSlug": "roundtrip",
"tierName": "Standard",
"startInTrial": false,
"trialEndsAt": null,
"stripeSubscriptionId": "sub_1AbCdEfGhIjKlMnO"
}

Product and tier are identified by slug/name, not Command Center's internal Guids — the calling product knows its own plan names, not Command Center's internal IDs.

Validation:

  • productSlug — required
  • tierName — required
  • trialEndsAt — required when startInTrial is true

On success — Response 201:

{
"subscriptionId": "...",
"tenantId": "...",
"productSlug": "roundtrip",
"tier": "Standard",
"status": "Active",
"effectivePrice": 79.00,
"startedAt": "2026-09-06T14:00:00Z",
"trialEndsAt": null,
"cancelledAt": null
}

effectivePrice is computed the same way as GET /v1/entitlements §3.11 — this is the concrete form of "checks for an active PriceCommitment line... first."

Response 404: Tenant, product, or tier not found (checked in that order).

Response 409 (Conflict): A subscription for this (tenant, product) pair already exists. Confirmed design: one persistent row per (Tenant, Product), enforced by a real database unique index — a resubscription updates the existing row via PATCH /v1/subscriptions/{id} (§3.14), it never creates a second row. The response error message points the caller at the correct endpoint to use instead.

Response 422: Validation failure (missing/empty productSlug or tierName, or startInTrial: true without trialEndsAt).


3.14 Update Subscription Status​

PATCH /v1/subscriptions/{id}
Service-to-service. Per BRD §5.2 — called by each product's own Stripe webhook handler on a status change. Live.

Request body:

{
"status": "PastDue"
}

Valid values: Active, PastDue, Suspended, Cancelled.

Trial is deliberately not a valid value here — Subscription.Create() (triggered by POST /v1/subscriptions) is the only legitimate way a subscription ever starts in Trial; there's no supported transition into Trial from any other state. Sending "status": "Trial" returns a 422.

On success — Response 200: same shape as POST /v1/subscriptions's 201 response (§3.13), reflecting the new status and recomputed effective price.

Response 404: Subscription not found.

Response 422, two different causes with the same status code:

  • "status" isn't one of the four valid values (including Trial) — caught by validation before the handler runs.
  • The requested transition isn't allowed from the subscription's current state — e.g. attempting to Activate an already-Cancelled subscription. This is the Subscription entity's own guard throwing, not a validation rule; cancelling an already-cancelled subscription is the one exception; that's treated as a harmless no-op, not an error.

4. Error Handling​

All endpoints return consistent error responses:

{
"success": false,
"errorMessage": "Human-readable error message",
"errorCode": "OPTIONAL_CODE"
}

Note: this is the original shape used by the three public endpoints (§3.2–3.4). The four service-to-service endpoints added in this revision (§3.11–3.14) and Suite Subscriptions (§3.10) use Result<T>/ResultExtensions's shape instead — a plain {"error": "..."} body for failures, with the HTTP status code itself carrying the meaning (404/409/422). Both shapes are real and currently live in different parts of the API; unifying them is a deliberate, deferred decision (see command-center-framing.md), not an oversight.

HTTP status codes:

CodeMeaning
200Success
201Created (subscription created — §3.13)
400Bad request (malformed JSON)
401Missing or invalid API key (service endpoints)
404Resource not found (tenant, product, tier, or subscription)
409Conflict (duplicate subscription — §3.13)
422Validation failure, or a business-rule violation on a state transition
429Rate limit exceeded
500Internal error
503Upstream service (RoundTrip, SendGrid) unavailable

5. Versioning​

All endpoints are versioned via URL prefix (/v1/). Breaking changes require a new version (/v2/). The v1 contract is stable — no breaking changes to existing v1 endpoints.

Adding new endpoints to v1 is not a breaking change.


6. Document History​

VersionDateAuthorChanges
1.0July 2026Pete CarrollInitial draft
2.0September 2026Pete Carroll (with Claude)Corrected §3.10's Suite Subscriptions implementation note (reads Command Center's own database, never queried Stripe). Added §3.11 (Entitlements), §3.12–3.14 (Subscriptions list/create/update) — all four fully built and tested, none previously documented here. Updated §2.2's service-to-service endpoint list and §4's error handling to reflect both response shapes actually in use.