API DESIGN DOCUMENT
Roundtrip · A Traxs Company Product · Version 1.0 · March 2026
RESTful API specification for the Roundtrip field service management platform. Covers all endpoints, request/response schemas, authentication, error handling, and versioning conventions.
| Attribute | Value |
|---|---|
| Document Title | Roundtrip API Design Document |
| Version | 1.0 — Initial Release |
| Status | Draft — In Review |
| API Style | RESTful HTTP / JSON |
| API Framework | FastEndpoints (ASP.NET Core 9) |
| Authentication | Keycloak JWT (Bearer token) |
| Documentation | Scalar (OpenAPI 3.0) |
| Base URL | https://api.roundtrip.app/v1 |
| Date | March 2026 |
01 — API CONVENTIONS
API Conventions
URL Structure
Base URL
https://api.roundtrip.app/v1/
Tenant-Scoped Resources
All tenant-scoped resources resolve the TenantId from the JWT — it is never passed in the URL.
GET /v1/tickets— list tickets for authenticated tenantGET /v1/tickets/{ticketId}— get specific ticket (must belong to tenant)POST /v1/tickets— create ticket in authenticated tenant
Nested Resources
Nested resources are used for clear ownership.
GET /v1/tickets/{ticketId}/parts— part usages on a specific ticketPOST /v1/tickets/{ticketId}/parts— add part usage to ticketGET /v1/clients/{clientId}/history— service history for a client
Actions
Non-CRUD operations use POST with a verb in the path.
POST /v1/tickets/{ticketId}/assign— assign ticket to technicianPOST /v1/tickets/{ticketId}/complete— complete ticketPOST /v1/invoices/{invoiceId}/send— send invoice to clientPOST /v1/routing/optimize— trigger route optimization
HTTP Methods
| Method | Used For | Body | Idempotent |
|---|---|---|---|
| GET | Retrieve resources. Never mutates state. | None | Yes |
| POST | Create new resources or trigger actions. | JSON request body | No |
| PUT | Full replacement of a resource. | Complete JSON resource | Yes |
| PATCH | Partial update of specific fields only. | JSON with changed fields only | No |
| DELETE | Soft-delete a resource (never physical delete on tenant data). | None | Yes |
HTTP Status Codes
| Code | Name | When Used |
|---|---|---|
| 200 | OK | Successful GET, PATCH, or PUT. Response body contains the resource. |
| 201 | Created | Successful POST that creates a resource. Location header contains the new resource URL. |
| 204 | No Content | Successful DELETE or action with no response body. |
| 400 | Bad Request | Validation failure. Response body is Problem Details with an errors array. |
| 401 | Unauthorized | Missing or invalid/expired JWT. Client should redirect to Keycloak login. |
| 403 | Forbidden | Valid JWT but insufficient role/permission for this operation. |
| 404 | Not Found | Resource does not exist, or exists but belongs to a different tenant (indistinguishable by design). |
| 409 | Conflict | State conflict (e.g., trying to complete an already-completed ticket). |
| 422 | Unprocessable | Business rule violation (e.g., insufficient stock). Distinct from 400 validation error. |
| 429 | Too Many Requests | Rate limit exceeded. Retry-After header indicates when to retry. |
| 500 | Internal Server Error | Unexpected server error. Correlation ID in response for support tracing. |
Standard Error Response (RFC 7807 Problem Details)
// 400 Validation Error
{
"type": "https://roundtrip.app/errors/validation",
"title": "Validation Failed",
"status": 400,
"traceId": "00-4af7e3b2c9d1e4f6-a1b2c3d4e5f6a7b8-01",
"errors": {
"ServiceDescription": ["Service description is required"],
"Priority": ["Priority must be one of: Low, Normal, High, Urgent"]
}
}
// 422 Business Rule Violation
{
"type": "https://roundtrip.app/errors/business-rule",
"title": "Cannot Complete Ticket",
"status": 422,
"detail": "Ticket is not in InProgress status. Current status: OnHold.",
"traceId": "00-9bf2e1a3c8d4f7b5-b2c3d4e5f6a7b8c9-01"
}
Authentication
Every API request (except /health and public invoice view) requires a valid Keycloak-issued JWT in the Authorization header. The JWT contains the tenant ID (tid claim) used for automatic tenant scoping.
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
JWT Claims Used by Roundtrip
Note: Auth provider switched to Entra External ID — claim names below reflect the original Keycloak integration and should be verified against the current provider.
sub— Keycloak user ID (unique within realm)tid— Tenant ID (custom claim added by Keycloak mapper)realm_access.roles— User roles:["Dispatcher", "Technician", etc.]email— User emailgiven_name,family_name— User display name
Pagination
Cursor-Based Pagination
Cursor-based pagination is used on all list endpoints.
GET /v1/tickets?limit=50&cursor=eyJpZCI6IjEyMzQ1In0=
Response includes pagination metadata:
{
"data": [ ... ],
"pagination": {
"limit": 50,
"hasNextPage": true,
"nextCursor": "eyJpZCI6IjY3ODkwIn0=",
"totalCount": 342
}
}
totalCount is included on the first page only — it is expensive to compute.
Rate Limiting
| Scope | Limit | Window | Header |
|---|---|---|---|
| Per tenant (Standard) | 1,000 requests | Per minute | X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset |
| Per tenant (Professional/Enterprise) | 5,000 requests | Per minute | Same headers |
| POST /v1/routing/optimize | 5 calls | Per hour per tenant | Prevents repeated optimization hammering |
| POST /v1/auth/* (Keycloak) | 20 attempts | Per 15 minutes per IP | Handled by Keycloak, not API |
02 — TICKETS API
Tickets API
GET /v1/tickets — List tickets for tenant (paginated)
| Auth | Dispatcher, BillingStaff, TenantAdmin, ReadOnly |
| Response | 200 { data: TicketSummary[], pagination: PaginationMeta } — TicketSummary: { ticketId, ticketNumber, clientName, serviceAddress, status, priority, technicianName?, createdAt, updatedAt } |
| Notes | Query params: status, technicianId, priority, clientId, from (date), to (date), search (full-text), cursor, limit (max 200). Default sort: createdAt DESC. |
GET /v1/tickets/{ticketId} — Get full ticket detail
| Auth | All roles (Technician: own assigned tickets only) |
| Response | 200 TicketDetail — includes all TicketSummary fields + serviceDescription, completionNote, onHoldReason, requestedWindow, partUsages[], notes[], attachments[], history[] |
| Error Codes | 404 if ticket not found or belongs to different tenant |
POST /v1/tickets — Create a new service ticket
| Auth | Dispatcher, TenantAdmin |
| Request Body | { clientId: uuid, clientAddressId: uuid, serviceDescription: string (min 10), priority: Low|Normal|High|Urgent, technicianId?: uuid, requestedWindowStart?: time, requestedWindowEnd?: time } |
| Response | 201 { ticketId, ticketNumber } — Location: /v1/tickets/{ticketId} |
| Error Codes | 400 validation errors · 404 if clientId or clientAddressId not found · 422 if clientAddressId doesn't belong to clientId |
| Notes | Raises TicketCreated domain event. If technicianId provided, also raises TicketAssigned. SignalR push to dispatcher dashboard. |
POST /v1/tickets/{ticketId}/assign — Assign or reassign ticket to a technician
| Auth | Dispatcher, TenantAdmin |
| Request Body | { technicianId: uuid, note?: string } |
| Response | 200 { ticketId, technicianId, status: 'Assigned' } |
| Error Codes | 404 ticket not found · 404 technician not found · 422 if ticket is Completed or Cancelled |
| Notes | If ticket already has a different technician, raises TicketReassigned. Both technicians notified via SignalR. |
POST /v1/tickets/{ticketId}/start — Technician marks ticket as In Progress
| Auth | Technician (own tickets only), Dispatcher, TenantAdmin |
| Request Body | (no body) |
| Response | 200 { ticketId, status: 'InProgress', startedAt } |
| Error Codes | 422 if status is not Assigned |
POST /v1/tickets/{ticketId}/complete — Complete a service ticket
| Auth | Technician (own tickets only), Dispatcher, TenantAdmin |
| Request Body | { completionNote: string (min 10) } |
| Response | 200 { ticketId, status: 'Completed', completedAt } |
| Error Codes | 422 if status is not InProgress · 400 if completionNote missing or < 10 chars |
| Notes | Raises TicketCompleted event. Billing context handler enqueues GenerateInvoicePdf Hangfire job. |
POST /v1/tickets/{ticketId}/hold — Put an in-progress ticket on hold
| Auth | Technician (own tickets only), Dispatcher, TenantAdmin |
| Request Body | { reason: AwaitingParts|CustomerNotHome|Rescheduled|AwaitingApproval } |
| Response | 200 { ticketId, status: 'OnHold', onHoldReason } |
| Error Codes | 422 if status is not InProgress |
POST /v1/tickets/{ticketId}/cancel — Cancel a ticket
| Auth | Dispatcher, TenantAdmin |
| Request Body | { reason?: string } |
| Response | 200 { ticketId, status: 'Cancelled' } |
| Error Codes | 422 if ticket is already Completed or Cancelled |
POST /v1/tickets/{ticketId}/parts — Record a part usage on a ticket
| Auth | Technician (own tickets only), Dispatcher |
| Request Body | { inventoryItemId: uuid, quantityUsed: int (>0), photoBlobKey?: string } |
| Response | 201 { partUsageId, inventoryItemId, itemName, quantityUsed, unitCostAmount, recordedAt } |
| Error Codes | 404 item not found · 422 if ticket is Completed/Cancelled · 422 if insufficient stock |
| Notes | Raises PartUsageRecorded domain event. Inventory context handler decrements stock. |
03 — CLIENTS API
Clients API
GET /v1/clients — List clients for tenant
| Auth | Dispatcher, BillingStaff, TenantAdmin, ReadOnly |
| Response | 200 { data: ClientSummary[], pagination } — ClientSummary: { clientId, displayName, primaryPhone, email, primaryAddress, ticketCount, lastServiceDate, isArchived } |
| Notes | Query params: search (name, phone, email), isArchived (default false), cursor, limit. Full-text search across name, phone, email. |
GET /v1/clients/{clientId} — Get full client detail
| Auth | Dispatcher, BillingStaff, TenantAdmin, ReadOnly |
| Response | 200 ClientDetail — includes all summary fields + serviceAddresses[], notes, preferredTechnicianId |
| Error Codes | 404 not found |
POST /v1/clients — Create a new client
| Auth | Dispatcher, TenantAdmin |
| Request Body | { firstName, lastName, companyName?, isCommercial, email?, primaryPhone?, serviceAddress: AddressInput, addressLabel?: string, preferredTechnicianId?: uuid } |
| Response | 201 { clientId } |
| Error Codes | 400 validation · 409 if email or phone matches existing client (with conflicting clientId in response) |
| Notes | Service address is geocoded asynchronously via Nominatim Hangfire job after creation. |
PATCH /v1/clients/{clientId} — Update client contact information
| Auth | Dispatcher, TenantAdmin |
| Request Body | Partial: { firstName?, lastName?, companyName?, email?, primaryPhone?, secondaryPhone?, notes?, preferredTechnicianId? } |
| Response | 200 ClientDetail |
| Error Codes | 404 not found · 400 validation · 409 duplicate email/phone |
GET /v1/clients/{clientId}/history — Get full service history for a client
| Auth | Dispatcher, BillingStaff, TenantAdmin, ReadOnly |
| Response | 200 { data: TicketHistoryItem[], summary: { totalTickets, totalSpent, avgCompletionHours, lastServiceDate }, pagination } |
| Notes | Query params: from, to, status, cursor. Includes invoiced amounts per ticket. |
POST /v1/clients/{clientId}/addresses — Add a new service address to a client
| Auth | Dispatcher, TenantAdmin |
| Request Body | { label, street, city, stateProvince, postalCode, country, isPrimary? } |
| Response | 201 { clientAddressId } |
| Notes | If isPrimary=true, previous primary is demoted. |
04 — INVENTORY API
Inventory API
GET /v1/inventory — List inventory items
| Auth | All roles |
| Response | 200 { data: InventoryItemSummary[], pagination } — Summary: { itemId, sku, name, category, unitOfMeasure, quantityOnHand, reorderThreshold, isLowStock, unitCostAmount } |
| Notes | Query params: category, isLowStock (bool), isArchived (default false), search (sku/name/barcode), cursor, limit. |
GET /v1/inventory/low-stock — Get items at or below reorder threshold
| Auth | Dispatcher, TenantAdmin |
| Response | 200 { data: LowStockItem[] } — LowStockItem includes item detail + upcomingTicketsNeedingItem count |
| Notes | No pagination — this list should always be short. Items ordered by (quantityOnHand - reorderThreshold) ASC (most critical first). |
POST /v1/inventory — Create a new inventory item
| Auth | TenantAdmin, Dispatcher |
| Request Body | { sku, name, description?, category?, unitOfMeasure, unitCostAmount, currency, quantityOnHand, reorderThreshold, barcode?, photoBlobKey? } |
| Response | 201 { inventoryItemId } |
| Error Codes | 409 if SKU already exists for this tenant |
PATCH /v1/inventory/{itemId}/quantity — Adjust stock quantity (manual correction)
| Auth | TenantAdmin, Dispatcher |
| Request Body | { newQuantity: int (>=0), reason: ReceivedShipment|Damaged|Theft|AuditCorrection|InitialSetup } |
| Response | 200 { itemId, quantityOnHand, movementId } |
| Error Codes | 400 if newQuantity < 0 · 422 if item is archived |
GET /v1/inventory/{itemId}/movements — Get stock movement history for an item
| Auth | TenantAdmin, Dispatcher |
| Response | 200 { data: StockMovement[], pagination } — StockMovement: { type, quantityDelta, quantityAfter, ticketId?, reason?, recordedBy, recordedAt } |
| Notes | Query params: from, to, type (Consumption|Adjustment|Receipt), cursor. |
05 — BILLING API
Billing API
POST /v1/invoices — Generate an invoice from a completed ticket
| Auth | BillingStaff, TenantAdmin |
| Request Body | { ticketId: uuid, taxRatePercent?: decimal (0–1), dueDate?: datetime } |
| Response | 201 { invoiceId, invoiceNumber } |
| Error Codes | 404 ticket not found · 422 if ticket is not Completed · 409 if invoice already exists for this ticket |
| Notes | Line items pre-populated from PartUsages on the ticket. Labour line item added based on ticket duration × tenant labour rate. BillingStaff can edit line items while Invoice is in Draft status. |
GET /v1/invoices/{invoiceId} — Get invoice detail with all line items
| Auth | BillingStaff, TenantAdmin, ReadOnly |
| Response | 200 InvoiceDetail — includes lineItems[], deliveries[], status, totals, clientInfo |
PATCH /v1/invoices/{invoiceId}/line-items — Add, update, or remove line items (Draft invoices only)
| Auth | BillingStaff, TenantAdmin |
| Request Body | { lineItems: LineItemInput[] } — full replacement of line items array |
| Response | 200 InvoiceDetail |
| Error Codes | 422 if invoice is not in Draft status (IsLocked = true) |
POST /v1/invoices/{invoiceId}/send — Send invoice to client via email and/or SMS
| Auth | BillingStaff, TenantAdmin |
| Request Body | { channels: ('email'|'sms')[], emailOverride?: string, ccEmails?: string[] } |
| Response | 200 { invoiceId, sentAt, status: 'Sent' } |
| Error Codes | 422 if invoice has no line items · 422 if already Paid or Cancelled · 422 if client has no email (for email channel) |
| Notes | Enqueues SendInvoiceEmail and/or SendInvoiceSMS Hangfire jobs. Invoice locked from editing after send. |
GET /v1/invoices/{invoiceId}/pdf — Download invoice as PDF
| Auth | BillingStaff, TenantAdmin |
| Response | 200 application/pdf binary stream |
| Notes | PDF generated by QuestPDF. Cached in blob storage after first generation. |
POST /v1/invoices/{invoiceId}/payment — Record a manual payment against an invoice
| Auth | BillingStaff, TenantAdmin |
| Request Body | { paidAt?: datetime, note?: string } |
| Response | 200 { invoiceId, status: 'Paid', paidAt } |
| Error Codes | 422 if invoice is not Sent or Overdue |
POST /v1/quotes — Create a new quote
| Auth | Dispatcher, BillingStaff, TenantAdmin |
| Request Body | { clientId, ticketId?, lineItems: LineItemInput[], expiresAt, termsAndConditions? } |
| Response | 201 { quoteId, quoteNumber } |
POST /v1/quotes/{quoteId}/send — Send quote to client
| Auth | Dispatcher, BillingStaff, TenantAdmin |
| Request Body | { channels: ('email'|'sms')[] } |
| Response | 200 { quoteId, sentAt, status: 'Sent' } |
POST /v1/quotes/{quoteId}/convert — Convert approved quote to invoice
| Auth | BillingStaff, TenantAdmin |
| Request Body | (no body) |
| Response | 201 { invoiceId, invoiceNumber } |
| Error Codes | 422 if quote is not in Approved status |
06 — ROUTING API
Routing API
GET /v1/routing/{technicianId} — Get today's route for a technician
| Auth | Technician (own route only), Dispatcher, TenantAdmin |
| Response | 200 TechnicianRoute — includes routeId, date, isOptimized, optimizedAt, stops[]: { stopOrder, ticketId, clientName, address, estimatedArrival, drivingMinutesPrev, isCompleted, isPinned } |
| Notes | Query param: date (defaults to today). Returns 404 if no route exists for that date. |
GET /v1/routing — Get all technician routes for a given date
| Auth | Dispatcher, TenantAdmin |
| Response | 200 { routes: TechnicianRouteSummary[] } — one per technician with assigned tickets |
| Notes | Query param: date (required). Used by dispatcher map to show all routes. |
POST /v1/routing/optimize — Trigger route optimization for one or all technicians
| Auth | Dispatcher, TenantAdmin |
| Request Body | { date: date (defaults to today), technicianId?: uuid (null = all technicians) } |
| Response | 202 Accepted { jobId: string } — optimization runs asynchronously via Hangfire |
| Error Codes | 429 if called more than 5 times per hour for this tenant |
| Notes | Client polls GET /v1/routing/{technicianId}?date= to check when routes are updated, or listens to SignalR 'RouteOptimized' event on dispatch-{tenantId} group. |
GET /v1/routing/unscheduled — Get tickets not yet on any route for a given date
| Auth | Dispatcher, TenantAdmin |
| Response | 200 { tickets: UnscheduledTicket[] } — UnscheduledTicket: { ticketId, clientName, address, priority, technicianId? } |
| Notes | Query param: date. Used by optimization workflow to fetch stops to be scheduled. |
07 — TECHNICIANS API
Technicians API
GET /v1/technicians — List all active technicians with current status
| Auth | Dispatcher, TenantAdmin |
| Response | 200 { data: TechnicianSummary[] } — TechnicianSummary: { technicianId, fullName, status, lastKnownLat, lastKnownLng, locationUpdatedAt, openTicketCount, skills[] } |
| Notes | Used by dispatch board map to populate technician pins. |
PATCH /v1/technicians/{technicianId}/location — Update technician GPS location
| Auth | Technician (own record only) |
| Request Body | { latitude: decimal, longitude: decimal } |
| Response | 200 { technicianId, latitude, longitude, updatedAt } |
| Error Codes | 403 if Technician tries to update another technician's location |
| Notes | Called by PWA every 60 seconds when app is active. Raises LocationUpdated domain event. SignalR pushes updated location to dispatch-{tenantId} group. |
PATCH /v1/technicians/{technicianId}/status — Update technician availability status
| Auth | Technician (own), Dispatcher, TenantAdmin |
| Request Body | { status: Available|EnRoute|OnSite|Unavailable|OffDuty } |
| Response | 200 { technicianId, status } |
| Notes | Status change reflected immediately on dispatch board via SignalR. |
08 — REAL-TIME & NOTIFICATIONS
Real-Time API (SignalR)
Hub Connection
// Client connects to SignalR hub
const connection = new HubConnectionBuilder()
.withUrl('https://api.roundtrip.app/hubs/dispatch', {
accessTokenFactory: () => getAccessToken()
})
.withAutomaticReconnect()
.build();
await connection.start();
Hub Groups
| Group Name | Who Joins | Events Received |
|---|---|---|
dispatch-{tenantId} | Dispatchers and TenantAdmins on login | TicketCreated, TicketAssigned, StatusChanged, TicketCompleted, LocationUpdated, RouteOptimized |
technician-{technicianId} | Technician on login (their own group) | TicketAssigned, RouteUpdated, DispatcherMessage |
Server-to-Client Events
| Event Name | Group | Payload |
|---|---|---|
| TicketCreated | dispatch-{tenantId} | { ticketId, ticketNumber, clientName, priority, status } |
| TicketAssigned | dispatch-{tenantId} + technician-{id} | { ticketId, ticketNumber, technicianId, technicianName } |
| StatusChanged | dispatch-{tenantId} | { ticketId, newStatus, technicianId, timestamp } |
| LocationUpdated | dispatch-{tenantId} | { technicianId, latitude, longitude, updatedAt } |
| RouteOptimized | dispatch-{tenantId} + technician-{id} | { technicianId, routeDate, stopCount, version } |
| LowStockAlert | dispatch-{tenantId} | { inventoryItemId, itemName, quantityOnHand, reorderThreshold } |
09 — REPORTS API
Reports API
GET /v1/reports/dashboard — Get KPI data for the operations dashboard
| Auth | Dispatcher, TenantAdmin |
| Response | 200 { ticketsToday, ticketsThisMonth, ticketsLastMonth, avgCompletionHours, firstTimeFixRate, technicianUtilisation, weeklyVolume[13], ticketsByStatus, revenueThisMonth, revenueLastMonth } |
| Notes | Query param: timezone (defaults to tenant timezone). Data cached in Redis with 15-minute TTL. |
GET /v1/reports/tickets — Paginated ticket report with filters
| Auth | TenantAdmin, BillingStaff, ReadOnly |
| Response | 200 { data: TicketReportRow[], pagination } — includes ticket number, client, technician, status, created, completed, duration, partsTotal, invoiceTotal |
| Notes | Query params: from (required), to, technicianId, status, clientId. Max date range 90 days per request. format=csv returns CSV download. |
GET /v1/reports/revenue — Revenue by period report
| Auth | TenantAdmin, BillingStaff |
| Response | 200 { rows: RevenueRow[] } — RevenueRow: { period, invoiceCount, totalInvoiced, totalCollected, totalOutstanding } |
| Notes | Query params: groupBy=month|week, from, to. format=csv for export. |
GET /v1/reports/technician-performance — Per-technician performance metrics
| Auth | TenantAdmin |
| Response | 200 { technicians: TechnicianPerformance[] } — includes name, ticketsCompleted, avgCompletionHours, firstTimeFixRate, totalPartsUsedCost |
| Notes | Query params: from, to. Only TenantAdmin can see individual technician performance data. |
10 — TENANT ADMIN API
Tenant Admin API
GET /v1/admin/settings — Get tenant settings
| Auth | TenantAdmin |
| Response | 200 TenantSettings — { companyName, timezone, defaultCurrency, distanceUnit, businessHours, notificationPreferences, taxRatePercent, logoUrl } |
PUT /v1/admin/settings — Update tenant settings (full replacement)
| Auth | TenantAdmin |
| Request Body | TenantSettings (full object) |
| Response | 200 TenantSettings |
| Notes | All settings changes are audit-logged. |
GET /v1/admin/users — List all users in the tenant (technicians + office staff)
| Auth | TenantAdmin |
| Response | 200 { users: UserSummary[] } — includes userId, name, email, role, isActive, lastLoginAt |
POST /v1/admin/users/invite — Invite a new user to the tenant
| Auth | TenantAdmin |
| Request Body | { email, firstName, lastName, role: Dispatcher|Technician|BillingStaff|ReadOnly } |
| Response | 201 { userId, invitationSentAt } |
| Error Codes | 409 if email already exists in this tenant · 422 if tenant is at seat limit for their plan |
| Notes | Triggers Keycloak invitation email with activation link (72-hour expiry). |
PATCH /v1/admin/users/{userId}/deactivate — Deactivate a user account
| Auth | TenantAdmin |
| Request Body | (no body) |
| Response | 200 { userId, isActive: false } |
| Error Codes | 422 if attempting to deactivate the last active TenantAdmin |
Roundtrip · API Design Document v1.0 · A Traxs Company Product · March 2026