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:
- §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/subscriptionswas actually implemented, it reads Command Center's ownSubscriptiontable directly, the same source of truth every other endpoint in this document already assumes exists. Corrected below. GET /v1/entitlementsandGET/POST/PATCH /v1/subscriptionswere 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.- §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/contactPOST /v1/supportPOST /v1/feedbackGET /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— liveGET /v1/subscriptions,POST /v1/subscriptions,PATCH /v1/subscriptions/{id}— liveGET /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:
| Value | Name | Description |
|---|---|---|
| 0 | GeneralInquiry | General question |
| 1 | Support | Support request |
| 2 | Feedback | Product feedback |
| 3 | DemoRequest | Demo booking request |
Validation:
firstName— required, max 100 charslastName— required, max 100 charsemail— required, valid email format, max 255 charsmessage— required, max 5000 charscompany— optional, max 200 charstype— required, valid enum value
On success:
- 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
- From:
- Send notification email to
hello@traxsgroup.com- Subject:
[{type}] New contact from {firstName} {lastName} - Body: full submission details
- Subject:
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 charsproduct— required, one of:RoundTrip,Waypoint,Relay,Generalpriority— optional, one of:Low,Normal,High,Critical
On success:
- Send confirmation email to submitter
- Send notification email to
support@roundtrips.app - 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:
- Send notification to
hello@traxsgroup.com - 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:
| Parameter | Type | Required | Description |
|---|---|---|---|
tenantId | Guid | Yes | The Traxs tenant ID |
period | string | No | mtd (default), ytd, last30, last90 |
How it works:
- Command Center API receives request from Waypoint
- Validates API key
- Calls RoundTrip API:
GET /v1/internal/revenue-summary?tenantId={id}&period={period} - RoundTrip API validates that the tenantId exists and has an active subscription
- Returns revenue data
- 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:
| Parameter | Type | Required |
|---|---|---|
tenantId | Guid | Yes |
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:
| Parameter | Type | Required | Default |
|---|---|---|---|
tenantId | Guid | Yes | — |
count | int | No | 5 |
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:
| Parameter | Type | Required |
|---|---|---|
tenantId | Guid | Yes |
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:
| Parameter | Type | Required |
|---|---|---|
tenantId | Guid | Yes |
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:
| Parameter | Type | Required |
|---|---|---|
tenantId | Guid | Yes |
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:
| Parameter | Type | Required |
|---|---|---|
tenantId | Guid | Yes |
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— requiredtierName— requiredtrialEndsAt— required whenstartInTrialistrue
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 (includingTrial) — caught by validation before the handler runs.- The requested transition isn't allowed from the subscription's current state — e.g. attempting to
Activatean already-Cancelledsubscription. This is theSubscriptionentity'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:
| Code | Meaning |
|---|---|
| 200 | Success |
| 201 | Created (subscription created — §3.13) |
| 400 | Bad request (malformed JSON) |
| 401 | Missing or invalid API key (service endpoints) |
| 404 | Resource not found (tenant, product, tier, or subscription) |
| 409 | Conflict (duplicate subscription — §3.13) |
| 422 | Validation failure, or a business-rule violation on a state transition |
| 429 | Rate limit exceeded |
| 500 | Internal error |
| 503 | Upstream 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
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | July 2026 | Pete Carroll | Initial draft |
| 2.0 | September 2026 | Pete 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. |