Skip to main content

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.

AttributeValue
Document TitleRoundtrip API Design Document
Version1.0 — Initial Release
StatusDraft — In Review
API StyleRESTful HTTP / JSON
API FrameworkFastEndpoints (ASP.NET Core 9)
AuthenticationKeycloak JWT (Bearer token)
DocumentationScalar (OpenAPI 3.0)
Base URLhttps://api.roundtrip.app/v1
DateMarch 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 tenant
  • GET /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 ticket
  • POST /v1/tickets/{ticketId}/parts — add part usage to ticket
  • GET /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 technician
  • POST /v1/tickets/{ticketId}/complete — complete ticket
  • POST /v1/invoices/{invoiceId}/send — send invoice to client
  • POST /v1/routing/optimize — trigger route optimization

HTTP Methods

MethodUsed ForBodyIdempotent
GETRetrieve resources. Never mutates state.NoneYes
POSTCreate new resources or trigger actions.JSON request bodyNo
PUTFull replacement of a resource.Complete JSON resourceYes
PATCHPartial update of specific fields only.JSON with changed fields onlyNo
DELETESoft-delete a resource (never physical delete on tenant data).NoneYes

HTTP Status Codes

CodeNameWhen Used
200OKSuccessful GET, PATCH, or PUT. Response body contains the resource.
201CreatedSuccessful POST that creates a resource. Location header contains the new resource URL.
204No ContentSuccessful DELETE or action with no response body.
400Bad RequestValidation failure. Response body is Problem Details with an errors array.
401UnauthorizedMissing or invalid/expired JWT. Client should redirect to Keycloak login.
403ForbiddenValid JWT but insufficient role/permission for this operation.
404Not FoundResource does not exist, or exists but belongs to a different tenant (indistinguishable by design).
409ConflictState conflict (e.g., trying to complete an already-completed ticket).
422UnprocessableBusiness rule violation (e.g., insufficient stock). Distinct from 400 validation error.
429Too Many RequestsRate limit exceeded. Retry-After header indicates when to retry.
500Internal Server ErrorUnexpected 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 email
  • given_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

ScopeLimitWindowHeader
Per tenant (Standard)1,000 requestsPer minuteX-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
Per tenant (Professional/Enterprise)5,000 requestsPer minuteSame headers
POST /v1/routing/optimize5 callsPer hour per tenantPrevents repeated optimization hammering
POST /v1/auth/* (Keycloak)20 attemptsPer 15 minutes per IPHandled by Keycloak, not API

02 — TICKETS API

Tickets API

GET /v1/tickets — List tickets for tenant (paginated)

AuthDispatcher, BillingStaff, TenantAdmin, ReadOnly
Response200 { data: TicketSummary[], pagination: PaginationMeta } — TicketSummary: { ticketId, ticketNumber, clientName, serviceAddress, status, priority, technicianName?, createdAt, updatedAt }
NotesQuery 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

AuthAll roles (Technician: own assigned tickets only)
Response200 TicketDetail — includes all TicketSummary fields + serviceDescription, completionNote, onHoldReason, requestedWindow, partUsages[], notes[], attachments[], history[]
Error Codes404 if ticket not found or belongs to different tenant

POST /v1/tickets — Create a new service ticket

AuthDispatcher, TenantAdmin
Request Body{ clientId: uuid, clientAddressId: uuid, serviceDescription: string (min 10), priority: Low|Normal|High|Urgent, technicianId?: uuid, requestedWindowStart?: time, requestedWindowEnd?: time }
Response201 { ticketId, ticketNumber } — Location: /v1/tickets/{ticketId}
Error Codes400 validation errors · 404 if clientId or clientAddressId not found · 422 if clientAddressId doesn't belong to clientId
NotesRaises 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

AuthDispatcher, TenantAdmin
Request Body{ technicianId: uuid, note?: string }
Response200 { ticketId, technicianId, status: 'Assigned' }
Error Codes404 ticket not found · 404 technician not found · 422 if ticket is Completed or Cancelled
NotesIf ticket already has a different technician, raises TicketReassigned. Both technicians notified via SignalR.

POST /v1/tickets/{ticketId}/start — Technician marks ticket as In Progress

AuthTechnician (own tickets only), Dispatcher, TenantAdmin
Request Body(no body)
Response200 { ticketId, status: 'InProgress', startedAt }
Error Codes422 if status is not Assigned

POST /v1/tickets/{ticketId}/complete — Complete a service ticket

AuthTechnician (own tickets only), Dispatcher, TenantAdmin
Request Body{ completionNote: string (min 10) }
Response200 { ticketId, status: 'Completed', completedAt }
Error Codes422 if status is not InProgress · 400 if completionNote missing or < 10 chars
NotesRaises TicketCompleted event. Billing context handler enqueues GenerateInvoicePdf Hangfire job.

POST /v1/tickets/{ticketId}/hold — Put an in-progress ticket on hold

AuthTechnician (own tickets only), Dispatcher, TenantAdmin
Request Body{ reason: AwaitingParts|CustomerNotHome|Rescheduled|AwaitingApproval }
Response200 { ticketId, status: 'OnHold', onHoldReason }
Error Codes422 if status is not InProgress

POST /v1/tickets/{ticketId}/cancel — Cancel a ticket

AuthDispatcher, TenantAdmin
Request Body{ reason?: string }
Response200 { ticketId, status: 'Cancelled' }
Error Codes422 if ticket is already Completed or Cancelled

POST /v1/tickets/{ticketId}/parts — Record a part usage on a ticket

AuthTechnician (own tickets only), Dispatcher
Request Body{ inventoryItemId: uuid, quantityUsed: int (>0), photoBlobKey?: string }
Response201 { partUsageId, inventoryItemId, itemName, quantityUsed, unitCostAmount, recordedAt }
Error Codes404 item not found · 422 if ticket is Completed/Cancelled · 422 if insufficient stock
NotesRaises PartUsageRecorded domain event. Inventory context handler decrements stock.

03 — CLIENTS API

Clients API

GET /v1/clients — List clients for tenant

AuthDispatcher, BillingStaff, TenantAdmin, ReadOnly
Response200 { data: ClientSummary[], pagination } — ClientSummary: { clientId, displayName, primaryPhone, email, primaryAddress, ticketCount, lastServiceDate, isArchived }
NotesQuery 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

AuthDispatcher, BillingStaff, TenantAdmin, ReadOnly
Response200 ClientDetail — includes all summary fields + serviceAddresses[], notes, preferredTechnicianId
Error Codes404 not found

POST /v1/clients — Create a new client

AuthDispatcher, TenantAdmin
Request Body{ firstName, lastName, companyName?, isCommercial, email?, primaryPhone?, serviceAddress: AddressInput, addressLabel?: string, preferredTechnicianId?: uuid }
Response201 { clientId }
Error Codes400 validation · 409 if email or phone matches existing client (with conflicting clientId in response)
NotesService address is geocoded asynchronously via Nominatim Hangfire job after creation.

PATCH /v1/clients/{clientId} — Update client contact information

AuthDispatcher, TenantAdmin
Request BodyPartial: { firstName?, lastName?, companyName?, email?, primaryPhone?, secondaryPhone?, notes?, preferredTechnicianId? }
Response200 ClientDetail
Error Codes404 not found · 400 validation · 409 duplicate email/phone

GET /v1/clients/{clientId}/history — Get full service history for a client

AuthDispatcher, BillingStaff, TenantAdmin, ReadOnly
Response200 { data: TicketHistoryItem[], summary: { totalTickets, totalSpent, avgCompletionHours, lastServiceDate }, pagination }
NotesQuery params: from, to, status, cursor. Includes invoiced amounts per ticket.

POST /v1/clients/{clientId}/addresses — Add a new service address to a client

AuthDispatcher, TenantAdmin
Request Body{ label, street, city, stateProvince, postalCode, country, isPrimary? }
Response201 { clientAddressId }
NotesIf isPrimary=true, previous primary is demoted.

04 — INVENTORY API

Inventory API

GET /v1/inventory — List inventory items

AuthAll roles
Response200 { data: InventoryItemSummary[], pagination } — Summary: { itemId, sku, name, category, unitOfMeasure, quantityOnHand, reorderThreshold, isLowStock, unitCostAmount }
NotesQuery 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

AuthDispatcher, TenantAdmin
Response200 { data: LowStockItem[] } — LowStockItem includes item detail + upcomingTicketsNeedingItem count
NotesNo pagination — this list should always be short. Items ordered by (quantityOnHand - reorderThreshold) ASC (most critical first).

POST /v1/inventory — Create a new inventory item

AuthTenantAdmin, Dispatcher
Request Body{ sku, name, description?, category?, unitOfMeasure, unitCostAmount, currency, quantityOnHand, reorderThreshold, barcode?, photoBlobKey? }
Response201 { inventoryItemId }
Error Codes409 if SKU already exists for this tenant

PATCH /v1/inventory/{itemId}/quantity — Adjust stock quantity (manual correction)

AuthTenantAdmin, Dispatcher
Request Body{ newQuantity: int (>=0), reason: ReceivedShipment|Damaged|Theft|AuditCorrection|InitialSetup }
Response200 { itemId, quantityOnHand, movementId }
Error Codes400 if newQuantity < 0 · 422 if item is archived

GET /v1/inventory/{itemId}/movements — Get stock movement history for an item

AuthTenantAdmin, Dispatcher
Response200 { data: StockMovement[], pagination } — StockMovement: { type, quantityDelta, quantityAfter, ticketId?, reason?, recordedBy, recordedAt }
NotesQuery params: from, to, type (Consumption|Adjustment|Receipt), cursor.

05 — BILLING API

Billing API

POST /v1/invoices — Generate an invoice from a completed ticket

AuthBillingStaff, TenantAdmin
Request Body{ ticketId: uuid, taxRatePercent?: decimal (0–1), dueDate?: datetime }
Response201 { invoiceId, invoiceNumber }
Error Codes404 ticket not found · 422 if ticket is not Completed · 409 if invoice already exists for this ticket
NotesLine 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

AuthBillingStaff, TenantAdmin, ReadOnly
Response200 InvoiceDetail — includes lineItems[], deliveries[], status, totals, clientInfo

PATCH /v1/invoices/{invoiceId}/line-items — Add, update, or remove line items (Draft invoices only)

AuthBillingStaff, TenantAdmin
Request Body{ lineItems: LineItemInput[] } — full replacement of line items array
Response200 InvoiceDetail
Error Codes422 if invoice is not in Draft status (IsLocked = true)

POST /v1/invoices/{invoiceId}/send — Send invoice to client via email and/or SMS

AuthBillingStaff, TenantAdmin
Request Body{ channels: ('email'|'sms')[], emailOverride?: string, ccEmails?: string[] }
Response200 { invoiceId, sentAt, status: 'Sent' }
Error Codes422 if invoice has no line items · 422 if already Paid or Cancelled · 422 if client has no email (for email channel)
NotesEnqueues SendInvoiceEmail and/or SendInvoiceSMS Hangfire jobs. Invoice locked from editing after send.

GET /v1/invoices/{invoiceId}/pdf — Download invoice as PDF

AuthBillingStaff, TenantAdmin
Response200 application/pdf binary stream
NotesPDF generated by QuestPDF. Cached in blob storage after first generation.

POST /v1/invoices/{invoiceId}/payment — Record a manual payment against an invoice

AuthBillingStaff, TenantAdmin
Request Body{ paidAt?: datetime, note?: string }
Response200 { invoiceId, status: 'Paid', paidAt }
Error Codes422 if invoice is not Sent or Overdue

POST /v1/quotes — Create a new quote

AuthDispatcher, BillingStaff, TenantAdmin
Request Body{ clientId, ticketId?, lineItems: LineItemInput[], expiresAt, termsAndConditions? }
Response201 { quoteId, quoteNumber }

POST /v1/quotes/{quoteId}/send — Send quote to client

AuthDispatcher, BillingStaff, TenantAdmin
Request Body{ channels: ('email'|'sms')[] }
Response200 { quoteId, sentAt, status: 'Sent' }

POST /v1/quotes/{quoteId}/convert — Convert approved quote to invoice

AuthBillingStaff, TenantAdmin
Request Body(no body)
Response201 { invoiceId, invoiceNumber }
Error Codes422 if quote is not in Approved status

06 — ROUTING API

Routing API

GET /v1/routing/{technicianId} — Get today's route for a technician

AuthTechnician (own route only), Dispatcher, TenantAdmin
Response200 TechnicianRoute — includes routeId, date, isOptimized, optimizedAt, stops[]: { stopOrder, ticketId, clientName, address, estimatedArrival, drivingMinutesPrev, isCompleted, isPinned }
NotesQuery 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

AuthDispatcher, TenantAdmin
Response200 { routes: TechnicianRouteSummary[] } — one per technician with assigned tickets
NotesQuery param: date (required). Used by dispatcher map to show all routes.

POST /v1/routing/optimize — Trigger route optimization for one or all technicians

AuthDispatcher, TenantAdmin
Request Body{ date: date (defaults to today), technicianId?: uuid (null = all technicians) }
Response202 Accepted { jobId: string } — optimization runs asynchronously via Hangfire
Error Codes429 if called more than 5 times per hour for this tenant
NotesClient 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

AuthDispatcher, TenantAdmin
Response200 { tickets: UnscheduledTicket[] } — UnscheduledTicket: { ticketId, clientName, address, priority, technicianId? }
NotesQuery 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

AuthDispatcher, TenantAdmin
Response200 { data: TechnicianSummary[] } — TechnicianSummary: { technicianId, fullName, status, lastKnownLat, lastKnownLng, locationUpdatedAt, openTicketCount, skills[] }
NotesUsed by dispatch board map to populate technician pins.

PATCH /v1/technicians/{technicianId}/location — Update technician GPS location

AuthTechnician (own record only)
Request Body{ latitude: decimal, longitude: decimal }
Response200 { technicianId, latitude, longitude, updatedAt }
Error Codes403 if Technician tries to update another technician's location
NotesCalled 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

AuthTechnician (own), Dispatcher, TenantAdmin
Request Body{ status: Available|EnRoute|OnSite|Unavailable|OffDuty }
Response200 { technicianId, status }
NotesStatus 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 NameWho JoinsEvents Received
dispatch-{tenantId}Dispatchers and TenantAdmins on loginTicketCreated, TicketAssigned, StatusChanged, TicketCompleted, LocationUpdated, RouteOptimized
technician-{technicianId}Technician on login (their own group)TicketAssigned, RouteUpdated, DispatcherMessage

Server-to-Client Events

Event NameGroupPayload
TicketCreateddispatch-{tenantId}{ ticketId, ticketNumber, clientName, priority, status }
TicketAssigneddispatch-{tenantId} + technician-{id}{ ticketId, ticketNumber, technicianId, technicianName }
StatusChangeddispatch-{tenantId}{ ticketId, newStatus, technicianId, timestamp }
LocationUpdateddispatch-{tenantId}{ technicianId, latitude, longitude, updatedAt }
RouteOptimizeddispatch-{tenantId} + technician-{id}{ technicianId, routeDate, stopCount, version }
LowStockAlertdispatch-{tenantId}{ inventoryItemId, itemName, quantityOnHand, reorderThreshold }

09 — REPORTS API

Reports API

GET /v1/reports/dashboard — Get KPI data for the operations dashboard

AuthDispatcher, TenantAdmin
Response200 { ticketsToday, ticketsThisMonth, ticketsLastMonth, avgCompletionHours, firstTimeFixRate, technicianUtilisation, weeklyVolume[13], ticketsByStatus, revenueThisMonth, revenueLastMonth }
NotesQuery param: timezone (defaults to tenant timezone). Data cached in Redis with 15-minute TTL.

GET /v1/reports/tickets — Paginated ticket report with filters

AuthTenantAdmin, BillingStaff, ReadOnly
Response200 { data: TicketReportRow[], pagination } — includes ticket number, client, technician, status, created, completed, duration, partsTotal, invoiceTotal
NotesQuery 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

AuthTenantAdmin, BillingStaff
Response200 { rows: RevenueRow[] } — RevenueRow: { period, invoiceCount, totalInvoiced, totalCollected, totalOutstanding }
NotesQuery params: groupBy=month|week, from, to. format=csv for export.

GET /v1/reports/technician-performance — Per-technician performance metrics

AuthTenantAdmin
Response200 { technicians: TechnicianPerformance[] } — includes name, ticketsCompleted, avgCompletionHours, firstTimeFixRate, totalPartsUsedCost
NotesQuery 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

AuthTenantAdmin
Response200 TenantSettings{ companyName, timezone, defaultCurrency, distanceUnit, businessHours, notificationPreferences, taxRatePercent, logoUrl }

PUT /v1/admin/settings — Update tenant settings (full replacement)

AuthTenantAdmin
Request BodyTenantSettings (full object)
Response200 TenantSettings
NotesAll settings changes are audit-logged.

GET /v1/admin/users — List all users in the tenant (technicians + office staff)

AuthTenantAdmin
Response200 { users: UserSummary[] } — includes userId, name, email, role, isActive, lastLoginAt

POST /v1/admin/users/invite — Invite a new user to the tenant

AuthTenantAdmin
Request Body{ email, firstName, lastName, role: Dispatcher|Technician|BillingStaff|ReadOnly }
Response201 { userId, invitationSentAt }
Error Codes409 if email already exists in this tenant · 422 if tenant is at seat limit for their plan
NotesTriggers Keycloak invitation email with activation link (72-hour expiry).

PATCH /v1/admin/users/{userId}/deactivate — Deactivate a user account

AuthTenantAdmin
Request Body(no body)
Response200 { userId, isActive: false }
Error Codes422 if attempting to deactivate the last active TenantAdmin

Roundtrip · API Design Document v1.0 · A Traxs Company Product · March 2026