Advance Scheduling API Integration Using Xtime
Author
- Sanket Mal
Last Updated Date
[2026-04-01]
Version History
| Version | Date | Changes | Author |
|---|---|---|---|
| 1.0 | 2026-03-19 | Initial draft | Sanket Mal |
| 2.0 | 2026-03-25 | Align all API contracts with actual implementation; update DateTime handling to offset-embedded flow; fix API 2/3/4/5/6/7 models and JSON examples | Sanket Mal |
| 3.0 | 2026-04-01 | Align API 5 update documentation with implemented enhanced update flow, add frontend payload scenarios, fix AppointmentSortBy enum mismatch, and remove scheduler-specific sections from this document | Sanket Mal |
Overview
This document is the API-focused source of truth for the advance scheduling feature. It covers the customer-facing APIs, request and response contracts, Xtime mappings, persistence behavior, and the exact frontend payload semantics required by the implemented backend.
What Is Advance Scheduling?
Advance scheduling allows a customer to book a car service appointment at an RK Auto dealership on a future date. It is powered by the Cox Automotive Xtime API (Service Scheduling API).
This is different from the existing instant service (a technician comes to the customer's location via a van). Both features eventually merge into a unified "My Appointments" view for the customer, but their backends are completely independent.
| Dimension | Instant Service | Advance Scheduling |
|---|---|---|
| Who does the work | RK Auto technician (van dispatch) | Dealership service center |
| Booking target | Customer's current location | Dealership location at a future time |
| Backend | Internal (our own DB, vans, techs) | Hybrid (own DB for appointment reads + Xtime API for booking operations) |
| Status tracking | Real-time location + status updates | Local DB status returned by appointment APIs |
Architectural Decisions
Decision 1: Where Does Appointment Data Live?
Our database is the system of record, not Xtime.
Every appointment is created through our backend. We write to our DB as part of every booking. All customer-facing queries (upcoming appointments, history) come from our DB, not from Xtime. Xtime is called during booking, update, cancellation, and appointment-validation flows.
Why not query Xtime directly for appointments?
- Xtime
GET /appointmentsreturns only ONE VIN at a time, so N API calls per customer - Slow, quota-consuming, and fails if any single VIN call fails
- Xtime only returns upcoming/active appointments, zero historical data
- Our DB gives us pagination, filtering, sorting, and reporting with zero external dependency
Decision 2: This Document Covers APIs, Not Scheduler Logic
Advance scheduling statuses such as Booked, InProgress, Completed, CancelledByCustomer, and CancelledByDealer still exist in the backend and are returned by the read APIs.
However, this document intentionally does not describe how those statuses are changed outside the customer-facing APIs. Scheduler and background-processing details belong in a separate feature document.
Decision 3: API 5 Uses Enhanced Update Semantics
The implemented update API is not a full-replacement API.
It uses null-safe update semantics:
| Field state sent by frontend | Meaning |
|---|---|
null | Keep existing value |
| Non-null value | Update that field |
services: [] | Clear all standalone services |
servicePackage: { "opcode": "", "serviceName": "" } | Clear service package |
For Xtime compatibility, when frontend sends null for services or servicePackage, backend reconstructs the current Xtime appointment service context and sends it back during update.
Decision 4: VIN-Only vs Full Vehicle Object
Frontend sends only the VIN. Backend sends VIN-only to Xtime initially. Xtime performs its own VIN decode for DealerService. If any Xtime endpoint rejects a VIN-only vehicle object, the backend enriches it by looking up year/make/model from our customervehicle table without any frontend involvement.
Decision 5: Customer PII - Use Xtime as Source of Truth
Problem: Xtime requires firstName, lastName, and at least one phone number (phoneNumber, workPhoneNumber, or mobilePhoneNumber) for booking. If the customer data in OUR DB doesn't match what Xtime has on file, the booking or update will fail with a validation error (e.g., "lastName mismatch", "phone number mismatch").
Solution: Before booking, the backend calls Xtime's GET /customers?vin={vin} to fetch the customer's information as Xtime knows it, then uses THAT data for the booking call.
Flow:
1. Frontend sends booking request (services, time, transport, comment)
Frontend NEVER sends customer personal info.
2. Backend calls: GET /customers?vin={vin}&dealerCode={code}
Xtime returns: { firstName, lastName, email, phone, workPhone, mobilePhone }
3. Backend constructs the Xtime booking payload using Xtime's customer data:
- firstName = from Xtime customer response
- lastName = from Xtime customer response
- phoneNumber = from Xtime customer response (phone or mobilePhone or workPhone)
4. Backend calls: POST /appointments-bookings/dealer/{dealerCode}
This ALWAYS matches because the data came from Xtime itself.
Why not use our own DB's customer data?
- Our DB may have a different spelling of the name (e.g., "Rob" vs "Robert")
- Our DB may have a different phone number than what the dealer has
- Xtime validates customer identity on UPDATE by matching firstName + lastName + phone against the existing appointment record
- Using Xtime's own data guarantees zero mismatch errors
Risk of data divergence: Customer info in our DB and Xtime may differ. This is acceptable. We are NOT syncing customer profiles between systems. Our DB stores our own user profile. Xtime stores the dealer's version of that customer. The booking call just needs to match Xtime's version, and by fetching first, it always will.
Xtime customer fields required by operation:
| Field | CREATE | UPDATE |
|---|---|---|
lastName | Required | Must match existing appointment |
firstName | Recommended (may be required) | Must match existing appointment if provided |
phoneNumber (or workPhoneNumber or mobilePhoneNumber) | At least ONE required | At least ONE must match existing |
emailAddress | Optional (not validated) | Optional |
Decision 6: Services and Packages — One Package Per Xtime Booking Call
A single appointment can contain:
- Zero, one, or many individual services
- Zero or one service package
Xtime's AppointmentBooking request model has servicePackage as a singular field — Xtime only supports one package per booking call. Our BookAppointmentRequest therefore accepts ServicePackage as a single nullable object (not a list). The frontend restricts selection to at most one package per appointment.
Our DB schema (advanceschedulingservicepackage) can store multiple packages per appointment for future flexibility, but at the API and service layer, only one package is accepted and sent to Xtime.
Decision 7: advisorId
Frontend does not send advisorId in booking or update requests.
For update, backend reads the current Xtime appointment and reuses its advisorId when building the outbound Xtime update payload so Xtime validation has the full appointment context.
Decision 8: DateTime Handling — Offset Embedded in Xtime's Response
There is no timezone config key needed for datetime conversion. Xtime returns available slots with the UTC offset already embedded in the datetime string (e.g., "2026-03-25T08:00-07:00"). The backend uses this embedded offset directly — no IANA timezone lookup, no XTIME_DEALER_TIMEZONE config for conversion.
Available Slots Flow
1. Backend calls Xtime → gets slots where each slot has a local datetime string
with embedded UTC offset (e.g. "2026-03-25T08:00-07:00")
2. Backend parses each string with DateTimeOffset.Parse() →
offset is extracted directly from the string itself (no config needed)
3. Backend returns each slot with two fields:
- appointmentDateTimeAsPerXtime: the original Xtime string ("2026-03-25T08:00-07:00")
- appointmentDateTimeInUtc: UTC equivalent derived from the embedded offset
Backend also returns dealerUtcOffset (e.g. "-07:00") extracted from the first slot
as an informational field — not used for conversion.
4. Frontend receives slots.
Frontend displays each appointment time by converting UTC → user's own device timezone.
Book Appointment Flow
5. User picks a slot.
Frontend sends:
- appointmentDateTimeAsPerXtime: the original Xtime string as received (e.g. "2026-03-25T08:00-07:00")
- VIN + services + transport type
6. Backend forwards appointmentDateTimeAsPerXtime directly to Xtime as-is —
zero conversion, zero config lookup.
7. Backend derives UTC from the embedded offset in the string:
DateTimeOffset.Parse("2026-03-25T08:00-07:00").UtcDateTime → stored as ScheduledDateTimeUtc
8. Backend stores the UTC offset string (e.g. "-07:00") in the DealerTimeZone column
for reference. This is NOT an IANA timezone name — it is the raw offset string.
View Appointment Flow
9. Backend returns AppointmentDateTimeInUtc (UTC) to frontend.
10. Frontend displays the time in the user's own device timezone —
same conversion as step 4. No server-side timezone conversion needed.
ScheduledDateTimeUtc is stored in DB in UTC. The DealerTimeZone column stores the raw offset string (e.g., "-07:00") captured from the request/slot selected in Xtime. It is a convenience reference, not an IANA timezone identifier and not the source of truth for conversion.
Decision 9: Audit Log - Phase 2
No audit log table in Phase 1. If a Xtime API call fails, the error is returned to the customer and nothing is partially written to the DB. The booking flow is atomic: DB write only happens after Xtime confirms. The audit log adds debugging value for production support but is unnecessary overhead at launch. Add it in Phase 2.
Xtime API Endpoints (Non-Deprecated Only)
These are the actual Xtime API endpoints we use. All deprecated endpoints are excluded.
| Xtime Endpoint | HTTP Method | Purpose | Our Connector Method |
|---|---|---|---|
GET /customers | GET | Get customer details by VIN | GetCustomer(vin) |
GET /appointments | GET | Get dealer appointments by VIN | GetCustomerAppointments(vin) |
POST /services/dealer/{dealerCode} | POST | Get available services + packages | DealerService(request) |
POST /transportation/options/{dealerCode} | POST | Get transport options with disclaimers | GetTransportOptions(request) (NEW) |
POST /appointments-availabilities/dealer/{dealerCode} | POST | Get available time slots | DealerAppointmentAvailabilites(request) |
POST /appointments-bookings/dealer/{dealerCode} | POST | Book or update appointment | AppointmentBooking(request) |
POST /appointments-cancellations/dealer/{dealerCode} | POST | Cancel appointment | AppointmentCancellation(request) |
GET /year-make-models/dealer/{dealerCode} | GET | Get dealer year/make/models | GetYMM() |
Our API Mapping to Xtime
| Our Endpoint | HTTP | Xtime Endpoint | Xtime HTTP | Notes |
|---|---|---|---|---|
/service-suggestions | GET | POST /services/dealer/{dealerCode} | POST | Returns services + packages + basic transport types |
/transport-options | POST | POST /transportation/options/{dealerCode} | POST | New method needed in connector |
/available-slots | POST | POST /appointments-availabilities/dealer/{dealerCode} | POST | Already in connector |
/appointment-book (create) | POST | POST /appointments-bookings/dealer/{dealerCode} | POST | Already in connector |
/appointment-update (update) | PUT | POST /appointments-bookings/dealer/{dealerCode} | POST | Same endpoint; appointmentId present = update |
/appointment-cancel | POST | POST /appointments-cancellations/dealer/{dealerCode} | POST | Already in connector |
/appointments (unified read) | GET | (none - DB only) | - | Filters: statuses, VIN, searchKeyword; pagination |
User Flow (Step-by-Step)
Booking Flow
Step 1: Customer selects one of their vehicles. Frontend knows the VIN from the user's vehicle list.
Step 2: Frontend calls GET /api/advance-scheduling/service-suggestions?vin={vin}
- Backend calls Xtime:
POST /services/dealer/{dealerCode} - Returns: available individual services + service packages + basic transport type indicators
Step 3: Customer selects one or more services and/or one package, then frontend calls POST /api/advance-scheduling/transport-options
- Backend calls Xtime:
POST /transportation/options/{dealerCode} - Returns: full transport options WITH per-option disclaimers (based on the specific services selected)
Step 4: Customer selects transport type + preferred date range, then frontend calls POST /api/advance-scheduling/available-slots
- Backend calls Xtime:
POST /appointments-availabilities/dealer/{dealerCode} - Xtime returns slots with embedded UTC offset in datetime string (e.g.
"2026-03-25T08:00-07:00") - Backend parses offset from string, derives UTC — no timezone config needed
- Returns:
appointmentDateTimeAsPerXtime(original Xtime string) +appointmentDateTimeInUtc(UTC) per slot
Step 5: Customer picks a time slot (optional: adds a comment), then frontend calls POST /api/advance-scheduling/appointment-book
- Frontend sends back
appointmentDateTimeAsPerXtimeexactly as received — no conversion - Backend calls Xtime:
GET /customers?vin={vin}to fetch customer PII - Backend forwards
appointmentDateTimeAsPerXtimeto Xtime as-is - Backend derives UTC from embedded offset, stores in DB; stores the offset string in
DealerTimeZonecolumn - Returns: booking confirmation with
xtimeAppointmentId - DB: INSERT into
advanceschedulingappointment+advanceschedulingservice+advanceschedulingservicepackage
Management Flows
Reschedule (update time/services):
Frontend calls PUT /api/advance-scheduling/appointment-update
- Backend looks up the local appointment by
xtimeAppointmentId - Backend calls Xtime:
GET /appointments?vin={vin}&dealerCode={code}to load the current Xtime appointment - Backend calls Xtime:
GET /customers?vin={vin}&dealerCode={code}to fetch customer PII - Backend rebuilds unchanged service/package context from the current Xtime appointment when frontend sends
null - Backend calls Xtime:
POST /appointments-bookings/dealer/{dealerCode}withappointmentIdset (= update mode) - DB: UPDATE appointment row and replace only the local service/package data explicitly changed by the request
Cancel:
Frontend calls POST /api/advance-scheduling/appointment-cancel
- Backend calls Xtime:
POST /appointments-cancellations/dealer/{dealerCode} - DB: UPDATE Status=
CancelledByCustomer, CancelBy=Customer, CancelledAt=NOW()
Read Flows (Our DB Only - No Xtime Calls)
GET /api/advance-scheduling/appointments— Unified endpoint with flexible filtering- Default (no filters): All statuses, all records
?statuses=Booked&statuses=InProgress— Upcoming appointments only?statuses=Completed&statuses=CancelledByCustomer&statuses=CancelledByDealer&pageNumber=1&rowsPerPage=10— History with pagination?vin=1FTHF35L0G0019158— Filter by exact VIN?searchKeyword=oil+change— Case-insensitive search across services, customer info, vehicle info
API Contracts
All our APIs use the same envelope pattern:
{ "success": true/false, "message": "...", /* data fields */ }
dealerCode is never sent by frontend or returned to frontend. Backend injects it from config for all Xtime calls.
API 1: Get Service Suggestions
GET /api/advance-scheduling/service-suggestions?vin={vin}
Purpose: Retrieve all available services and packages for a specific vehicle at the dealership. First step in the booking flow.
Xtime API called: POST /services/dealer/{dealerCode}
C# Models
Files: Our response:
Models/AdvanceScheduling/Responses.cs| Xtime models:Models/Xtime/Request.cs(XtimeServiceRequest, VehicleData),Models/Xtime/Response.cs(XtimeServicesResponse)
Xtime Request Model (existing: XtimeServiceRequest):
public class XtimeServiceRequest
{
[JsonPropertyName("vehicle")]
public VehicleData Vehicle { get; set; }
[JsonPropertyName("package")]
public bool Package { get; set; }
[JsonPropertyName("locale")]
public string Locale { get; set; } = "en-us";
}
public class VehicleData
{
[JsonPropertyName("vin")]
public string Vin { get; set; }
}
Xtime Response Model (existing: XtimeServicesResponse):
public class XtimeServicesResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("code")]
public string Code { get; set; }
[JsonPropertyName("message")]
public string Message { get; set; }
[JsonPropertyName("services")]
public List<XtimeService> Services { get; set; }
[JsonPropertyName("advisors")]
public List<Advisor> Advisors { get; set; }
[JsonPropertyName("transportTypes")]
public List<TransportType> TransportTypes { get; set; }
[JsonPropertyName("servicePackages")]
public List<ServicePackage> ServicePackages { get; set; }
[JsonPropertyName("tellusMore")]
public TellusMore TellusMore { get; set; }
}
Our Response Model (new: ServiceSuggestionsResponse):
public class ServiceSuggestionsResponse
{
public bool Success { get; set; }
public string Message { get; set; }
public List<ServiceSuggestionItem> Services { get; set; }
public List<ServicePackageSuggestionItem> ServicePackages { get; set; }
public List<TransportTypeItem> TransportTypes { get; set; }
}
public class ServiceSuggestionItem
{
public string ServiceName { get; set; }
public string Opcode { get; set; }
public string Price { get; set; }
public string Comment { get; set; }
public string ServiceCategoryId { get; set; }
public string ServiceCategoryName { get; set; }
}
public class ServicePackageSuggestionItem
{
public string ServiceName { get; set; }
public string Opcode { get; set; }
public float Price { get; set; }
public string Comment { get; set; }
public List<PackageServiceItem> PackageServices { get; set; }
}
public class PackageServiceItem
{
public string ServiceName { get; set; }
public string ServiceDescription { get; set; }
public float Price { get; set; }
}
public class TransportTypeItem
{
public string TransportType { get; set; }
public string Label { get; set; }
public bool LoanerAvailable { get; set; }
}
Xtime Request Payload
{
"vehicle": { "vin": "1HGBH41JXMN109186" },
"package": true,
"locale": "en-US"
}
Xtime Response (raw)
{
"success": true,
"code": null,
"message": "Success",
"services": [
{
"serviceName": "Oil Change",
"opcode": "10909807",
"price": "49.99",
"comment": null,
"serviceCategoryId": "MAINT001",
"serviceCategoryName": "Maintenance"
}
],
"advisors": [ { "advisorId": "ADV001", "advisorName": "Mike Smith" } ],
"transportTypes": [
{ "transportType": "DROPOFF", "label": "Drop Off", "loanerAvailable": false },
{ "transportType": "WAITER", "label": "Wait for Vehicle", "loanerAvailable": false }
],
"servicePackages": [
{
"serviceName": "30,000 Mile Service",
"opcode": "30000:PACKAGE:30K",
"price": 299.99,
"comment": null,
"packageServices": [
{ "serviceName": "Oil Change", "serviceDescription": "Full synthetic oil change", "price": 49.99 },
{ "serviceName": "Tire Rotation", "serviceDescription": "Rotate all 4 tires", "price": 29.99 }
]
}
],
"tellusMore": null
}
Our Response (for frontend)
Backend strips code, advisors[], tellusMore from Xtime's raw response.
{
"success": true,
"message": "Success",
"services": [
{
"serviceName": "Oil Change",
"opcode": "10909807",
"price": "49.99",
"comment": null,
"serviceCategoryId": "MAINT001",
"serviceCategoryName": "Maintenance"
},
{
"serviceName": "Tire Rotation",
"opcode": "10909808",
"price": "29.99",
"comment": null,
"serviceCategoryId": "MAINT001",
"serviceCategoryName": "Maintenance"
}
],
"servicePackages": [
{
"serviceName": "30,000 Mile Service",
"opcode": "30000:PACKAGE:30K",
"price": 299.99,
"comment": null,
"packageServices": [
{ "serviceName": "Oil Change", "serviceDescription": "Full synthetic oil change", "price": 49.99 },
{ "serviceName": "Tire Rotation", "serviceDescription": "Rotate all 4 tires", "price": 29.99 }
]
}
],
"transportTypes": [
{ "transportType": "DROPOFF", "label": "Drop Off", "loanerAvailable": false },
{ "transportType": "WAITER", "label": "Wait for Vehicle", "loanerAvailable": false },
{ "transportType": "SHUTTLE", "label": "Shuttle Service", "loanerAvailable": false },
{ "transportType": "VALET", "label": "Valet Service", "loanerAvailable": true }
]
}
transportTypeshere are basic indicators. Full options with disclaimers come in API 2.
Backend Logic
- Build
XtimeServiceRequestwith VIN,package=true,locale="en-US" - Inject
dealerCodefrom config - Call
XtimeConnector.DealerService() - Strip
code,advisors[],tellusMorefrom response - Return our shaped response
API 2: Get Transport Options
POST /api/advance-scheduling/transport-options
Purpose: Get transport options with full disclaimers based on the specific services the customer chose. Step 3 of the booking flow.
Xtime API called: POST /transportation/options/{dealerCode} (new - not in current connector)
C# Models
Files: Our request:
Models/AdvanceScheduling/Requests.cs| Our response:Models/AdvanceScheduling/Responses.cs| Xtime models:Models/Xtime/Request.cs(XtimeTransportOptionsRequest),Models/Xtime/Response.cs(XtimeTransportOptionsResponse)
Xtime Request Model (XtimeTransportOptionsRequest):
public class XtimeTransportOptionsRequest
{
[JsonPropertyName("vehicle")]
public required VehicleData Vehicle { get; set; }
[JsonPropertyName("services")]
public List<TransportServiceItem>? Services { get; set; }
/// <summary>
/// Xtime accepts a single servicePackage. If multiple packages are selected,
/// the service layer sends the first here and the rest as entries in Services[].
/// </summary>
[JsonPropertyName("servicePackage")]
public TransportServicePackageItem? ServicePackage { get; set; }
}
public class TransportServiceItem
{
[JsonPropertyName("opcode")]
public required string Opcode { get; set; }
[JsonPropertyName("serviceName")]
public string? ServiceName { get; set; }
[JsonPropertyName("price")]
public string? Price { get; set; } // Xtime expects price as string e.g. "29.99"
}
public class TransportServicePackageItem
{
[JsonPropertyName("opcode")]
public required string Opcode { get; set; }
[JsonPropertyName("serviceName")]
public string? ServiceName { get; set; }
[JsonPropertyName("price")]
public float? Price { get; set; }
}
Xtime Response Model (XtimeTransportOptionsResponse):
public class XtimeTransportOptionsResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("code")]
public string Code { get; set; }
[JsonPropertyName("message")]
public string Message { get; set; }
[JsonPropertyName("transportOptions")]
public List<TransportOptionItem> TransportOptions { get; set; }
}
public class TransportOptionItem
{
[JsonPropertyName("label")]
public string Label { get; set; }
[JsonPropertyName("data")]
public string Data { get; set; }
[JsonPropertyName("disclaimer")]
public string Disclaimer { get; set; }
}
Our Request Model (TransportOptionsRequest in Requests.cs):
public class TransportOptionsRequest
{
public required string Vin { get; set; }
/// <summary>List of service opcodes selected by the customer.</summary>
public List<string>? ServiceOpcodes { get; set; }
/// <summary>Single service package opcode selected by the customer. Xtime only supports one.</summary>
public string? ServicePackageOpcode { get; set; }
}
Our Response Model (TransportOptionsResponse in Responses.cs):
public class TransportOptionsResponse
{
public bool Success { get; set; }
public string? Message { get; set; }
public List<TransportOptionDto>? TransportOptions { get; set; }
}
public class TransportOptionDto
{
public required string Label { get; set; }
public required string Data { get; set; } // the transport type value to send back (e.g. "DROPOFF")
public string? Disclaimer { get; set; }
}
Our Request (from frontend)
{
"vin": "1HGBH41JXMN109186",
"serviceOpcodes": ["10909807", "10909808"],
"servicePackageOpcode": "30000:PACKAGE:30K"
}
| Field | Required | Notes |
|---|---|---|
vin | Yes | Vehicle VIN |
serviceOpcodes | Yes* | At least one opcode OR servicePackageOpcode required |
servicePackageOpcode | No | Single selected package opcode |
Xtime Request Payload
{
"vehicle": { "vin": "1HGBH41JXMN109186" },
"services": [
{ "opcode": "10909807" },
{ "opcode": "10909808" }
],
"servicePackage": { "opcode": "30000:PACKAGE:30K" }
}
Xtime Response (raw)
{
"success": true,
"code": null,
"message": null,
"transportOptions": [
{ "label": "Drop Off", "data": "DROPOFF", "disclaimer": "Drop off your vehicle and we'll call when ready" },
{ "label": "Wait for Vehicle", "data": "WAITER", "disclaimer": "Comfortable waiting area with WiFi" },
{ "label": "Shuttle Service", "data": "SHUTTLE", "disclaimer": "Complimentary shuttle service available" },
{ "label": "Rental Car", "data": "RENTAL", "disclaimer": "Rental car available (subject to availability)" },
{ "label": "Valet Service", "data": "VALET", "disclaimer": "We'll pick up and deliver your vehicle" }
]
}
Our Response (for frontend)
{
"success": true,
"message": null,
"transportOptions": [
{ "label": "Drop Off", "data": "DROPOFF", "disclaimer": "Drop off your vehicle and we'll call when ready" },
{ "label": "Wait for Vehicle", "data": "WAITER", "disclaimer": "Comfortable waiting area with WiFi" },
{ "label": "Shuttle Service", "data": "SHUTTLE", "disclaimer": "Complimentary shuttle service available" },
{ "label": "Rental Car", "data": "RENTAL", "disclaimer": "Rental car available (subject to availability)" },
{ "label": "Valet Service", "data": "VALET", "disclaimer": "We'll pick up and deliver your vehicle" }
]
}
The
datafield is the transport type value the frontend must send back in subsequent API calls.
Backend Logic
- Map
serviceOpcodes→Services[]in Xtime request (each as{ opcode: "..." }) - Map
servicePackageOpcode→servicePackagein Xtime request (as{ opcode: "..." }) - Call
XtimeConnector.GetTransportOptions() - Strip
code, return
API 3: Get Available Time Slots
POST /api/advance-scheduling/available-slots
Purpose: Get all available booking slots for the chosen services, transport type, and date range. Step 4 of the booking flow.
Xtime API called: POST /appointments-availabilities/dealer/{dealerCode}
C# Models
Files: Our request:
Models/AdvanceScheduling/Requests.cs| Our response:Models/AdvanceScheduling/Responses.cs| Xtime models:Models/Xtime/Request.cs(XtimeAppointmentAvailabilitesRequest),Models/Xtime/Response.cs(XtimeAppointmentAvailabilitesResponse)
Xtime Request Model (existing: XtimeAppointmentAvailabilitesRequest):
public class XtimeAppointmentAvailabilitesRequest
{
[JsonPropertyName("vehicle")]
public Vehicle Vehicle { get; set; }
[JsonPropertyName("opcode")]
public string Opcode { get; set; } // comma-separated opcodes
[JsonPropertyName("servicePackage")]
public string ServicePackage { get; set; } // single package opcode string
[JsonPropertyName("tellusMore")]
public string TellusMore { get; set; }
[JsonPropertyName("advisorId")]
public string AdvisorId { get; set; }
[JsonPropertyName("transportType")]
public string TransportType { get; set; }
[JsonPropertyName("start")]
public DateTime Start { get; set; }
[JsonPropertyName("end")]
public DateTime End { get; set; }
[JsonPropertyName("valet")]
public ValetData Valet { get; set; }
}
public class ValetData
{
[JsonPropertyName("pickupAddress")]
public string PickupAddress { get; set; }
[JsonPropertyName("dropOffAddress")]
public string DropOffAddress { get; set; }
[JsonPropertyName("comments")]
public string Comments { get; set; }
[JsonPropertyName("loaner")]
public bool Loaner { get; set; }
}
Xtime Response Model (existing: XtimeAppointmentAvailabilitesResponse):
public class XtimeAppointmentAvailabilitesResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("code")]
public string Code { get; set; }
[JsonPropertyName("message")]
public string Message { get; set; }
[JsonPropertyName("availableAppointments")]
public List<AvailableAppointment> AvailableAppointments { get; set; }
}
public class AvailableAppointment
{
[JsonPropertyName("durationMinutes")]
public int DurationMinutes { get; set; }
[JsonPropertyName("appointmentDateTimeLocal")]
public string AppointmentDateTimeLocal { get; set; }
}
Our Request Model (AvailableSlotsRequest in Requests.cs):
public class AvailableSlotsRequest
{
public required string Vin { get; set; }
public List<string>? OpcodeList { get; set; }
public string? ServicePackageOpcode { get; set; } // single package opcode
public required string TransportType { get; set; }
public required DateTime StartDate { get; set; }
public required DateTime EndDate { get; set; }
public ValetDto? Valet { get; set; } // only when TransportType = "VALET"
}
// Shared DTO (defined at top of Responses.cs, used here for reference)
public class ValetDto
{
public required string PickupAddress { get; set; }
public string? DropOffAddress { get; set; }
public string? Comments { get; set; }
public bool Loaner { get; set; }
}
Our Response Model (AvailableSlotsResponse in Responses.cs):
public class AvailableSlotsResponse
{
public bool Success { get; set; }
public string? Message { get; set; }
/// <summary>UTC offset of the dealer (e.g. "-07:00"), extracted from the first available slot. Informational only.</summary>
public string? DealerUtcOffset { get; set; }
public List<AvailableSlotDto>? AvailableAppointments { get; set; }
}
public class AvailableSlotDto
{
/// <summary>Exact local datetime string from Xtime (e.g. "2026-03-25T08:00-07:00"). Must be sent back as-is when booking.</summary>
public required string AppointmentDateTimeAsPerXtime { get; set; }
/// <summary>UTC equivalent, derived from the embedded offset. Frontend displays this in the user's device timezone.</summary>
public required DateTimeOffset AppointmentDateTimeInUtc { get; set; }
public int DurationMinutes { get; set; }
}
Our Request (from frontend)
{
"vin": "YV4612HM1G1016641",
"opcodeList": ["15861044", "10909808"],
"servicePackageOpcode": "90000:PACKAGE",
"transportType": "DROPOFF",
"startDate": "2026-03-25",
"endDate": "2026-03-30",
"valet": null
}
| Field | Required | Notes |
|---|---|---|
vin | Yes | Vehicle VIN |
opcodeList | Yes* | At least one opcode OR servicePackageOpcode required |
servicePackageOpcode | No | Single package opcode |
transportType | Yes | From API 2 response data field |
startDate / endDate | Yes | Date range as DateTime |
valet | No | Only when transportType = "VALET" |
Xtime Request Payload
{
"vehicle": { "vin": "YV4612HM1G1016641" },
"opcode": "15861044,10909808",
"servicePackage": "90000:PACKAGE",
"transportType": "DROPOFF",
"start": "2026-03-25",
"end": "2026-03-30"
}
Backend joins
opcodeListarray into a single comma-separated string foropcode.advisorIdintentionally omitted.
Xtime Response (raw)
Xtime returns each slot's datetime with the UTC offset embedded in the string:
{
"success": true,
"code": null,
"message": null,
"availableAppointments": [
{ "appointmentDateTimeLocal": "2026-03-25T08:00-07:00", "durationMinutes": 60 },
{ "appointmentDateTimeLocal": "2026-03-25T09:00-07:00", "durationMinutes": 60 },
{ "appointmentDateTimeLocal": "2026-03-25T10:00-07:00", "durationMinutes": 60 }
]
}
Our Response (for frontend)
Backend parses each slot using DateTimeOffset.Parse(), derives UTC from the embedded offset, and returns both the original Xtime string and the UTC equivalent. No timezone config key is used.
{
"success": true,
"message": null,
"dealerUtcOffset": "-07:00",
"availableAppointments": [
{
"appointmentDateTimeAsPerXtime": "2026-03-25T08:00-07:00",
"appointmentDateTimeInUtc": "2026-03-25T15:00:00+00:00",
"durationMinutes": 60
},
{
"appointmentDateTimeAsPerXtime": "2026-03-25T09:00-07:00",
"appointmentDateTimeInUtc": "2026-03-25T16:00:00+00:00",
"durationMinutes": 60
},
{
"appointmentDateTimeAsPerXtime": "2026-03-25T10:00-07:00",
"appointmentDateTimeInUtc": "2026-03-25T17:00:00+00:00",
"durationMinutes": 60
}
]
}
Frontend displays each appointment time by converting
appointmentDateTimeInUtc→ user's own device timezone.dealerUtcOffsetis informational only.When the user selects a slot to book, the frontend sends back
appointmentDateTimeAsPerXtimeexactly as received — no conversion.
Backend Logic
- Validate (VIN, at least one opcode/package, valid date range)
- Join
opcodeListinto comma-separated string foropcodefield - Build Xtime request (VIN-only vehicle, dates as
"YYYY-MM-DD"strings) - Call
XtimeConnector.DealerAppointmentAvailabilites() - For each returned slot:
DateTimeOffset.Parse(appointmentDateTimeLocal)→ extract UTC offset from result - Set
dealerUtcOffsetfrom the first slot's parsed offset (informational) - Return
AppointmentDateTimeAsPerXtime= original Xtime string,AppointmentDateTimeInUtc=.ToUniversalTime()
API 4: Book Appointment (Create)
POST /api/advance-scheduling/appointment-book
Purpose: Confirm a booking for the selected slot. Creates the appointment in Xtime and in our DB. Step 5 of the booking flow.
Xtime APIs called:
GET /customers?vin={vin}&dealerCode={code}- Fetch customer PII from XtimePOST /appointments-bookings/dealer/{dealerCode}- Create the appointment
C# Models
Files: Our request:
Models/AdvanceScheduling/Requests.cs| Our response:Models/AdvanceScheduling/Responses.cs| Xtime models:Models/Xtime/Request.cs(XtimeAppointmentBookingRequest),Models/Xtime/Response.cs(XtimeAppointmentBookingResponse, XtimeCustomerResponse)
Xtime Booking Request Model (existing: XtimeAppointmentBookingRequest):
public class XtimeAppointmentBookingRequest
{
[JsonPropertyName("vehicle")]
public required VehicleData Vehicle { get; set; }
[JsonPropertyName("appointmentId")]
public string? AppointmentId { get; set; } // null = create; non-null = update
[JsonPropertyName("appointmentDateTimeLocal")]
public required string AppointmentDateTimeLocal { get; set; } // forwarded as-is from frontend
[JsonPropertyName("firstName")]
public required string FirstName { get; set; }
[JsonPropertyName("lastName")]
public required string LastName { get; set; }
[JsonPropertyName("emailAddress")]
public string? EmailAddress { get; set; }
/// <summary>
/// Xtime requires at least ONE of phoneNumber / workPhoneNumber / mobilePhoneNumber.
/// All three are forwarded from Xtime's GET /customers response.
/// </summary>
[JsonPropertyName("phoneNumber")]
public string? PhoneNumber { get; set; }
[JsonPropertyName("workPhoneNumber")]
public string? WorkPhoneNumber { get; set; }
[JsonPropertyName("mobilePhoneNumber")]
public string? MobilePhoneNumber { get; set; }
[JsonPropertyName("comment")]
public string? Comment { get; set; }
[JsonPropertyName("transportType")]
public required string TransportType { get; set; }
[JsonPropertyName("services")]
public List<ServiceData>? Services { get; set; }
[JsonPropertyName("servicePackage")]
public ServicePackage? ServicePackage { get; set; }
[JsonPropertyName("valet")]
public Valet? Valet { get; set; }
}
Xtime Booking Response Model (existing: XtimeAppointmentBookingResponse):
public class XtimeAppointmentBookingResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("code")]
public string Code { get; set; }
[JsonPropertyName("message")]
public string Message { get; set; }
[JsonPropertyName("appointmentId")]
public string AppointmentId { get; set; }
}
Xtime Customer Response Model (existing: XtimeCustomerResponse):
public class XtimeCustomerResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("code")]
public string Code { get; set; }
[JsonPropertyName("message")]
public string Message { get; set; }
[JsonPropertyName("customer")]
public List<Customer> Customer { get; set; }
}
public class Customer
{
[JsonPropertyName("firstName")]
public string FirstName { get; set; }
[JsonPropertyName("lastName")]
public string LastName { get; set; }
[JsonPropertyName("email")]
public string Email { get; set; }
[JsonPropertyName("phone")]
public string Phone { get; set; }
[JsonPropertyName("workPhone")]
public string WorkPhone { get; set; }
[JsonPropertyName("mobilePhone")]
public string MobilePhone { get; set; }
}
Our Request Model (BookAppointmentRequest in Requests.cs):
public class BookAppointmentRequest
{
public required string Vin { get; set; }
/// <summary>
/// Exact local datetime string returned by Xtime available-slots
/// (e.g. "2026-03-25T08:00-07:00"). Forwarded directly to Xtime without conversion.
/// </summary>
public required string AppointmentDateTimeAsPerXtime { get; set; }
public required string TransportType { get; set; }
public ValetDto? Valet { get; set; }
public string? Comment { get; set; }
public List<BookingServiceItem>? Services { get; set; }
public BookingServicePackageItem? ServicePackage { get; set; } // single, not a list
}
public class BookingServiceItem
{
public required string ServiceName { get; set; }
public required string Opcode { get; set; }
public required string Price { get; set; }
public string? Comment { get; set; }
public string? ServiceCategoryId { get; set; }
public string? ServiceCategoryName { get; set; }
}
public class BookingServicePackageItem
{
public required string ServiceName { get; set; }
public required string Opcode { get; set; }
public decimal Price { get; set; }
public string? Comment { get; set; }
public List<PackageServiceItem>? PackageServices { get; set; }
}
Our Response Model (BookAppointmentResponse in Responses.cs):
public class BookAppointmentResponse
{
public bool Success { get; set; }
public string? Message { get; set; }
public required string XtimeAppointmentId { get; set; }
}
Our Request (from frontend)
{
"vin": "1FTHF35L0G0019158",
"appointmentDateTimeAsPerXtime": "2026-03-25T10:00-07:00",
"transportType": "VALET",
"valet": {
"pickupAddress": "123 Main Street, Springfield, IL 62701",
"dropOffAddress": "456 Work Avenue, Springfield, IL 62702",
"loaner": false
},
"comment": "Please also check the AC",
"services": [
{
"serviceName": "Oil Change",
"opcode": "10909807",
"price": "49.99",
"comment": null,
"serviceCategoryId": "MAINT001",
"serviceCategoryName": "Maintenance"
}
],
"servicePackage": {
"serviceName": "30,000 Mile Service",
"opcode": "30000:PACKAGE:30K",
"price": 299.99,
"comment": null,
"packageServices": [
{ "serviceName": "Oil Change", "serviceDescription": "Full synthetic oil change", "price": 49.99 },
{ "serviceName": "Tire Rotation", "serviceDescription": "Rotate all 4 tires", "price": 29.99 }
]
}
}
| Field | Required | Notes |
|---|---|---|
vin | Yes | Vehicle VIN |
appointmentDateTimeAsPerXtime | Yes | Exact string from available-slots response — forwarded as-is to Xtime |
transportType | Yes | From API 2 response data field |
valet | No | Only when transportType = "VALET" |
comment | No | Customer's note |
services | Yes* | At least one service or servicePackage required |
servicePackage | No | Single package object (not an array) |
firstName,lastName,emailAddress,phoneNumberare never sent by frontend. Backend fetches from Xtime's GET /customers API.
Xtime Request Payload (constructed by backend)
{
"vehicle": { "vin": "1FTHF35L0G0019158" },
"appointmentDateTimeLocal": "2026-03-25T10:00-07:00",
"firstName": "John",
"lastName": "Doe",
"emailAddress": "john.doe@example.com",
"workPhoneNumber": "555-123-4567",
"mobilePhoneNumber": "555-987-6543",
"transportType": "VALET",
"valet": { "pickupAddress": "...", "dropOffAddress": "...", "loaner": false },
"services": [
{ "serviceName": "Oil Change", "opcode": "10909807", "price": "49.99", "comment": null }
],
"servicePackage": {
"serviceName": "30,000 Mile Service",
"opcode": "30000:PACKAGE:30K",
"price": 299.99,
"packageServices": [
{ "serviceName": "Oil Change", "serviceDescription": "Full synthetic oil change", "price": 49.99 }
]
}
}
appointmentDateTimeLocalis the exactAppointmentDateTimeAsPerXtimestring from the frontend — it already carries the UTC offset (e.g."2026-03-25T10:00-07:00"). The backend forwards it unchanged.firstName,lastName,emailAddress,workPhoneNumber,mobilePhoneNumberall come from the XtimeGET /customersresponse, NOT from our DB.
Xtime Response (raw)
{
"success": true,
"code": null,
"message": "Appointment is successfully reserved.",
"appointmentId": "X123456789"
}
Our Response (for frontend)
{
"success": true,
"message": "Appointment is successfully reserved.",
"xtimeAppointmentId": "X123456789"
}
Backend Logic (atomic - DB write only on Xtime success)
- Extract
userIdfrom JWT - Call Xtime
GET /customers?vin={vin}&dealerCode={code}to get customer PII - Extract
firstName,lastName,workPhoneNumber,mobilePhoneNumber,emailAddressfrom Xtime response - Forward
AppointmentDateTimeAsPerXtimestring directly to Xtime asappointmentDateTimeLocal— the embedded UTC offset (e.g."-07:00") is already present, no conversion needed - Parse the same string with
DateTimeOffset.Parse()to derive UTC (ToUniversalTime()); store the offset portion (e.g."-07:00") in theDealerTimeZoneDB column for reference - Build full
XtimeAppointmentBookingRequestusing Xtime's customer info - Call
XtimeConnector.AppointmentBooking() - If Xtime fails: return error to customer, write nothing to DB
- If Xtime succeeds:
INSERTintoadvanceschedulingappointment(Status=Booked, XtimeAppointmentId=Xtime's appointmentId)INSERTrows intoadvanceschedulingservicefor each standalone service (ServicePackageId=NULL)INSERTrows intoadvanceschedulingservicepackagefor each package, thenINSERTcorresponding sub-service rows withServicePackageIdset
- Return Xtime's
xtimeAppointmentId
API 5: Update Appointment (Reschedule)
PUT /api/advance-scheduling/appointment-update
Purpose: Update an existing booked appointment via Xtime's Enhanced Update Flow. The appointment time is always rescheduled. Services, service package, transport type, valet details, and comment can also be changed.
Xtime APIs called:
GET /appointments?vin={vin}&dealerCode={code}- Load current Xtime appointmentGET /customers?vin={vin}&dealerCode={code}- Fetch customer PII (must match existing appointment)POST /appointments-bookings/dealer/{dealerCode}- Update the appointment
C# Models
Files: Our request:
Models/AdvanceScheduling/Requests.cs| Xtime models: same as API 4 (Models/Xtime/Request.cs,Models/Xtime/Response.cs)
Uses the same Xtime booking request model as API 4, but in update mode backend sets appointmentId and reconstructs unchanged service/package context from the current Xtime appointment when necessary.
Our Request Model (new: UpdateAppointmentRequest):
public class UpdateAppointmentRequest
{
public required string XtimeAppointmentId { get; set; }
public required string NewAppointmentDateTimeLocal { get; set; }
public string? TransportType { get; set; }
public ValetDto? Valet { get; set; }
public string? Comment { get; set; }
public List<BookingServiceItem>? Services { get; set; }
public BookingServicePackageItem? ServicePackage { get; set; }
}
Implemented Update Semantics
| Field | null means | Non-null value means | Empty value means |
|---|---|---|---|
transportType | Keep existing | Update transport type | n/a |
valet | Keep existing | Replace valet | n/a |
comment | Keep existing | Replace comment | Empty string is treated as update value |
services | Keep existing standalone services | Replace all standalone services | [] clears all standalone services |
servicePackage | Keep existing package | Replace package | { "opcode": "", "serviceName": "" } clears package |
Important Validation Rules
The backend rejects the update before any Xtime call in these cases:
xtimeAppointmentIdis missing.newAppointmentDateTimeLocalis missing.- Frontend tries to remove both services and package at the same time.
- Clearing package would leave the appointment with nothing booked.
- Clearing standalone services would leave the appointment with nothing booked.
Frontend Payload Scenarios
Scenario A: Change only appointment time
{
"xtimeAppointmentId": "X23XYZ456",
"newAppointmentDateTimeLocal": "2026-03-28T10:00-07:00",
"transportType": null,
"valet": null,
"comment": null,
"services": null,
"servicePackage": null
}
Scenario B: Replace standalone services, keep current package
{
"xtimeAppointmentId": "X23XYZ456",
"newAppointmentDateTimeLocal": "2026-03-28T10:00-07:00",
"transportType": null,
"valet": null,
"comment": null,
"services": [
{
"serviceName": "Brake Fluid Flush",
"opcode": "13441820",
"price": "0.0",
"comment": "0",
"serviceCategoryId": "1272",
"serviceCategoryName": "Brake Fluid - Flush"
}
],
"servicePackage": null
}
Scenario C: Clear all standalone services, keep current package
{
"xtimeAppointmentId": "X23XYZ456",
"newAppointmentDateTimeLocal": "2026-03-28T10:00-07:00",
"transportType": null,
"valet": null,
"comment": null,
"services": [],
"servicePackage": null
}
Scenario D: Replace package, keep current standalone services
{
"xtimeAppointmentId": "X23XYZ456",
"newAppointmentDateTimeLocal": "2026-03-28T10:00-07:00",
"transportType": null,
"valet": null,
"comment": null,
"services": null,
"servicePackage": {
"serviceName": "90,000 Mile Service",
"opcode": "90000:PACKAGE",
"price": 0,
"comment": null,
"packageServices": null
}
}
Scenario E: Clear package, keep current standalone services
{
"xtimeAppointmentId": "X23XYZ456",
"newAppointmentDateTimeLocal": "2026-03-28T10:00-07:00",
"transportType": null,
"valet": null,
"comment": null,
"services": null,
"servicePackage": {
"opcode": "",
"serviceName": ""
}
}
Scenario F: Replace services and clear package
{
"xtimeAppointmentId": "X23XYZ456",
"newAppointmentDateTimeLocal": "2026-03-28T10:00-07:00",
"transportType": null,
"valet": null,
"comment": null,
"services": [
{
"serviceName": "Brake Fluid Flush",
"opcode": "13441820",
"price": "0.0",
"comment": "0",
"serviceCategoryId": "1272",
"serviceCategoryName": "Brake Fluid - Flush"
}
],
"servicePackage": {
"opcode": "",
"serviceName": ""
}
}
Scenario G: Invalid request - remove both services and package
{
"xtimeAppointmentId": "X23XYZ456",
"newAppointmentDateTimeLocal": "2026-03-28T10:00-07:00",
"transportType": null,
"valet": null,
"comment": null,
"services": [],
"servicePackage": {
"opcode": "",
"serviceName": ""
}
}
This request is rejected before any Xtime call.
Xtime Request Payload
Same booking payload shape as API 4, but with appointmentId populated. Backend also fills unchanged fields from the current Xtime appointment when frontend sends null.
{
"vehicle": { "vin": "1FTHF35L0G0019158" },
"appointmentId": "X23XYZ456",
"appointmentDateTimeLocal": "2026-03-28T10:00-07:00",
"firstName": "John",
"lastName": "Doe",
"emailAddress": "john.doe@example.com",
"phoneNumber": "555-123-4567",
"workPhoneNumber": "555-123-4567",
"mobilePhoneNumber": "555-987-6543",
"advisorId": "ADV001",
"transportType": "DROPOFF",
"comment": "Changed my mind on the time",
"services": [
{ "serviceName": "Oil Change", "opcode": "10909807", "price": "49.99" }
],
"servicePackage": {
"serviceName": "30,000 Mile Service",
"opcode": "30000:PACKAGE:30K"
},
"valet": null
}
Customer info comes from Xtime
GET /customers, not from our DB. On update, Xtime validates that customer details match the existing appointment.Backend also calls Xtime
GET /appointmentsand reconstructs unchanged services/package so Xtime's internal option re-validation succeeds.
Xtime Response (raw)
{
"success": true,
"code": null,
"message": "Appointment is successfully reserved.",
"appointmentId": "X23XYZ456"
}
Our Response (for frontend)
{
"success": true,
"message": "Appointment is successfully updated.",
"xtimeAppointmentId": "X23XYZ456"
}
Backend Logic
- Look up appointment in our DB by
xtimeAppointmentId - Verify it belongs to the authenticated customer
- Verify status is
Booked - Call Xtime
GET /appointments?vin={vin}&dealerCode={code}and find the current appointment byappointmentId - Call Xtime
GET /customers?vin={vin}&dealerCode={code}to get customer PII - Resolve the effective update payload:
- caller-provided values win
nullmeans preserve existing values- if
servicesisnull, backend reuses existing standalone services from Xtime - if
servicePackageisnull, backend reuses existing package from Xtime services: []means clear standalone servicesservicePackage: { "opcode": "", "serviceName": "" }means clear package
- Validate that the appointment is not left with neither services nor package
- Call Xtime update via
POST /appointments-bookings/dealer/{dealerCode}withappointmentIdpopulated - If Xtime succeeds, persist local DB update:
- update appointment header
- replace standalone services only if
serviceswas supplied - replace service package only if
servicePackagewas supplied
API 6: Cancel Appointment
POST /api/advance-scheduling/appointment-cancel
Purpose: Cancel a booked appointment.
Xtime API called: POST /appointments-cancellations/dealer/{dealerCode}
C# Models
Files: Our request:
Models/AdvanceScheduling/Requests.cs| Our response:Models/AdvanceScheduling/Responses.cs| Xtime models:Models/Xtime/Request.cs(XtimeAppointmentCancellationRequest),Models/Xtime/Response.cs(XtimeAppointmentCancellationResponse)
Xtime Request Model (existing: XtimeAppointmentCancellationRequest):
public class XtimeAppointmentCancellationRequest
{
[JsonPropertyName("appointmentId")]
public string AppointmentId { get; set; }
}
Xtime Response Model (existing: XtimeAppointmentCancellationResponse):
public class XtimeAppointmentCancellationResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("code")]
public string Code { get; set; }
[JsonPropertyName("message")]
public string Message { get; set; }
}
Our Request Model (new: CancelAppointmentRequest):
public class CancelAppointmentRequest
{
public string XtimeAppointmentId { get; set; }
}
Our Response Model (new: CancelAppointmentResponse):
public class CancelAppointmentResponse
{
public bool Success { get; set; }
public string Message { get; set; }
public string XtimeAppointmentId { get; set; }
}
Our Request (from frontend)
{
"xtimeAppointmentId": "X123456789"
}
Xtime Request Payload
{
"appointmentId": "X123456789"
}
Xtime Response (raw)
{
"success": true,
"code": null,
"message": "Appointment cancelled successfully."
}
Our Response (for frontend)
{
"success": true,
"message": "Appointment cancelled successfully.",
"xtimeAppointmentId": "X123456789"
}
Backend Logic
- Look up appointment in our DB by
xtimeAppointmentId - Verify it belongs to the authenticated customer
- Verify status is
Bookedonly (cannot cancelInProgress,Completed, or alreadyCancelled) - Call
XtimeConnector.AppointmentCancellation() - If Xtime fails: return error, no DB changes
- If Xtime succeeds:
UPDATEStatus=CancelledByCustomer, CancelBy=Customer, CancelledAt=NOW()
- Return confirmation
API 7: Get Appointments (Unified with Robust Filtering)
GET /api/advance-scheduling/appointments
Purpose: Unified endpoint to retrieve all appointments with flexible filtering, sorting, and pagination. Replaces three separate endpoints (upcoming, all, history).
Xtime API called: None. Data source is our DB only.
Query Parameters:
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
statuses | AdvanceSchedulingStatus (repeated) | No | all | Use repeated params: ?statuses=Booked&statuses=InProgress. Values: Booked, InProgress, Completed, CancelledByCustomer, CancelledByDealer. If omitted, returns all. |
vin | string | No | - | Filter by vehicle VIN (exact match) |
searchKeyword | string | No | - | Search scope depends on caller role. Admin can search customer fields, vehicle fields, services, and package fields. Customer search excludes customer PII fields. |
pageNumber | int | No | 1 | Page number (1-indexed) |
rowsPerPage | int | No | 20 | Rows per page (1-1000) |
appointmentSortBy | AppointmentSortBy | No | AppointmentScheduleAt | Actual enum supports only AppointmentScheduleAt and AppointmentBookedAt |
sortDirection | SortDirection | No | DESC | ASC or DESC |
Example Requests
Get all upcoming appointments:
GET /api/advance-scheduling/appointments?statuses=Booked&statuses=InProgress
Get completed appointments with pagination:
GET /api/advance-scheduling/appointments?statuses=Completed&pageNumber=1&rowsPerPage=10
Get all appointments for a specific VIN:
GET /api/advance-scheduling/appointments?vin=1FTHF35L0G0019158
Search by keyword (name, email, phone, or VIN):
GET /api/advance-scheduling/appointments?searchKeyword=john
Get history (cancelled + completed) with keyword search:
GET /api/advance-scheduling/appointments?statuses=Completed&statuses=CancelledByCustomer&statuses=CancelledByDealer&searchKeyword=555-123&rowsPerPage=15
Sort by appointment booked date ascending:
GET /api/advance-scheduling/appointments?appointmentSortBy=AppointmentBookedAt&sortDirection=ASC
C# Models
Files:
Models/AdvanceScheduling/Requests.cs,Models/AdvanceScheduling/Responses.cs| Enum:Models/Common/Enum.cs(AdvanceSchedulingStatus)
Request Model (AppointmentFilter from query params — [FromQuery]):
public class AppointmentFilter
{
/// <summary>
/// List of statuses to filter by. Use repeated query params: ?statuses=Booked&statuses=InProgress
/// If null or empty, all statuses are included.
/// </summary>
public List<AdvanceSchedulingStatus>? Statuses { get; set; }
/// <summary>
/// Filter by exact VIN match. Optional.
/// </summary>
public string? Vin { get; set; }
/// <summary>
/// Search keyword — partial case-insensitive match across customer name, email, phone, or VIN. Optional.
/// </summary>
public string? SearchKeyword { get; set; }
/// <summary>
/// Page number (1-indexed). Default 1.
/// </summary>
public int PageNumber { get; set; } = 1;
/// <summary>
/// Rows per page. Default 20. Max 1000.
/// </summary>
public int RowsPerPage { get; set; } = 20;
/// <summary>
/// Sort order: "asc", "desc", or "auto" (default).
/// "auto" = active appointments (Booked, InProgress) first ASC, then history DESC.
/// </summary>
public AppointmentSortBy AppointmentSortBy { get; set; } = AppointmentSortBy.AppointmentScheduleAt;
public SortDirection SortDirection {get; set;} = SortDirection.DESC;
}
Status Enum (from Models/Common/Enum.cs):
public enum AdvanceSchedulingStatus
{
Booked = 1,
InProgress = 2,
Completed = 3,
CancelledByCustomer = 4,
CancelledByDealer = 5
}
public enum AppointmentSortBy
{
AppointmentScheduleAt = 1, // Scheduled date/time
AppointmentBookedAt = 2 // Booking creation date/time
}
public enum SortDirection
{
ASC = 1, // Ascending order
DESC // Descending order
}
Response Model — uses the shared ServerPaginatedData<AppointmentDto> (from Models/Common/CommonResponse.cs):
public class AppointmentDto
{
public Guid Id { get; set; }
public string? XtimeAppointmentId { get; set; }
public string? TransportType { get; set; }
public DateTime AppointmentDateTimeInUtc { get; set; }
public int EstimatedDurationMinutes { get; set; }
public string? Comment { get; set; }
public string? DmsNotes { get; set; }
public AdvanceSchedulingStatus Status { get; set; }
public string? CancelBy { get; set; } // "Customer", "Dealer", or null
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public VehicleInfoDto? VehicleInfo { get; set; }
public CustomerInfoDto? CustomerInfo { get; set; }
public List<ServiceDto>? Services { get; set; }
public List<ServicePackageDto>? ServicePackages { get; set; }
}
public class VehicleInfoDto
{
public string? Vin { get; set; }
public string? Make { get; set; }
public string? Model { get; set; }
public int? Year { get; set; }
}
public class CustomerInfoDto
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
}
public class ServiceDto
{
public Guid ServiceId { get; set; }
public string? Opcode { get; set; }
public string? ServiceName { get; set; }
public decimal? Price { get; set; }
public string? Comment { get; set; }
public string? ServiceCategoryId { get; set; }
public string? ServiceCategoryName { get; set; }
}
public class ServicePackageDto
{
public Guid ServicePackageId { get; set; }
public string? Opcode { get; set; }
public string? PackageName { get; set; }
public decimal? Price { get; set; }
public string? Comment { get; set; }
public List<PackageServiceItem>? PackageServices { get; set; }
}
Sample Response
{
"data": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"xtimeAppointmentId": "X123456789",
"transportType": "VALET",
"appointmentDateTimeInUtc": "2026-01-20T16:00:00Z",
"estimatedDurationMinutes": 90,
"comment": "Please check tire pressure",
"dmsNotes": null,
"status": "Booked",
"cancelBy": null,
"createdAt": "2026-01-15T14:30:00Z",
"updatedAt": "2026-01-15T14:30:00Z",
"vehicleInfo": {
"vin": "1FTHF35L0G0019158",
"make": "Ford",
"model": "F-150",
"year": 2016
},
"customerInfo": {
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@example.com",
"phone": "555-123-4567"
},
"services": [
{
"serviceId": "e5f6a7b8-c9d0-1234-5678-abcdef012345",
"opcode": "10909807",
"serviceName": "Oil Change",
"price": 49.99,
"comment": null,
"serviceCategoryId": "MAINT001",
"serviceCategoryName": "Maintenance"
}
],
"servicePackages": [
{
"servicePackageId": "c9d0e1f2-a3b4-5678-9012-cdef34567890",
"opcode": "30000:PACKAGE:30K",
"packageName": "30,000 Mile Service",
"price": 299.99,
"comment": null,
"packageServices": [
{ "serviceName": "Oil Change", "serviceDescription": "Full synthetic", "price": 49.99 },
{ "serviceName": "Tire Rotation", "serviceDescription": "4-tire rotation", "price": 29.99 }
]
}
]
},
{
"id": "b2c3d4e5-f6g7-0123-4567-a1b2c3d4e5f6",
"xtimeAppointmentId": "X987654321",
"transportType": "DROPOFF",
"appointmentDateTimeInUtc": "2025-12-10T16:00:00Z",
"estimatedDurationMinutes": 60,
"status": "Completed",
"cancelBy": null,
"createdAt": "2025-12-01T09:00:00Z",
"updatedAt": "2025-12-10T17:30:00Z",
"vehicleInfo": {
"vin": "YV4612HM1G1016641",
"make": "Volvo",
"model": "XC90",
"year": 2016
},
"customerInfo": {
"firstName": "Jane",
"lastName": "Smith",
"email": "jane.smith@example.com",
"phone": "555-987-6543"
},
"services": [],
"servicePackages": []
}
],
"totalNumber": 12,
"hasPreviousPage": false,
"hasNextPage": true,
"totalPages": 2,
"pageNumber": 1,
"rowsPerPage": 10
}
Backend Logic
- Extract caller identity and role from JWT
- Validate filters:
pageNumber >= 1rowsPerPage <= 1000
- Build WHERE clause based on role and filters:
WHERE (@customerId IS NULL OR asa.CustomerId = @customerId) AND (@statuses IS NULL OR asa.Status IN (@statuses)) AND (@vin IS NULL OR asa.Vin = @vin) AND ( @searchKeyword IS NULL OR ...role-dependent searchable fields... )
4. Count total matches
5. Apply sorting using `AppointmentSortBy` + `SortDirection`
6. Apply pagination
7. Batch-load services and packages for the returned appointment IDs
8. Return `ServerPaginatedData<AppointmentDto>` with nested `VehicleInfo` and `CustomerInfo` objects
---
## Database Design
Three tables. No audit log in Phase 1.
---
### Table 1: `advanceschedulingappointment`
The core appointment record. One row per customer booking.
**Column decisions:**
- `ScheduledDateTimeUtc` only, no local time column. Local time is always derived at response time using the embedded UTC offset stored in `DealerTimeZone`.
- `ValetJson` stays JSONB: only 3 fields, not queryable by business logic, nullable.
- `EstimatedDurationMinutes NOT NULL DEFAULT 60`: stored because Xtime returns duration and the client needs it back consistently. If Xtime doesn't return a duration, default to 60 minutes.
- `CancelBy`: distinguishes customer cancellation from dealer cancellation at the column level. Useful for reporting.
---
### Table 2: `advanceschedulingservice`
Individual services per appointment. One row per service selected. Services belonging to a package are also stored here with `ServicePackageId` set.
**`ServicePackageId` logic:**
- `NULL` = standalone service added directly by customer
- Set = this service is a sub-item of a package (e.g., "Oil Change" as part of "30K Service Package")
This design allows powerful queries:
- "All standalone services in appointment X": `WHERE AppointmentId=X AND ServicePackageId IS NULL`
- "All sub-services in package Y": `WHERE ServicePackageId=Y`
- "How many oil changes booked this month?": `WHERE Opcode='10909807'`
---
### Table 3: `advanceschedulingservicepackage`
Package selections per appointment. One row per package selected. Multiple packages per appointment are supported in our schema.
**`PackageServicesJson`:** Kept as JSONB for the package sub-service breakdown shown in the UI. The actual sub-services are also normalized in `advanceschedulingservice` with `ServicePackageId` set for reporting. `PackageServicesJson` is the display snapshot used to avoid a JOIN when rendering the package card.
---
### DB Insert Flow at Booking Time
Book Appointment | Xtime API succeeds -> XtimeAppointmentId = "X123456789" |
-
INSERT advanceschedulingappointment (CustomerId, VIN, XtimeAppointmentId, ScheduledDateTimeUtc, ...) -> returns appointmentId (UUID)
-
For each standalone service selected: INSERT advanceschedulingservice (AppointmentId=@appointmentId, ServicePackageId=NULL, Opcode, ServiceName, ...)
-
For each package selected: a. INSERT advanceschedulingservicepackage (AppointmentId=@appointmentId, Opcode, PackageName, Price, PackageServicesJson) -> returns servicePackageId (UUID)
b. For each sub-service in that package: INSERT advanceschedulingservice (AppointmentId=@appointmentId, ServicePackageId=@servicePackageId, Opcode, ServiceName, ...)
### DB Update Flow at Reschedule Time
- Verify appointment exists AND belongs to customer AND Status='Booked'
- Xtime API succeeds
- UPDATE advanceschedulingappointment SET ... WHERE Id = @appointmentId
- DELETE FROM advanceschedulingservice WHERE AppointmentId = @appointmentId
- DELETE FROM advanceschedulingservicepackage WHERE AppointmentId = @appointmentId
- Re-INSERT all services and packages (same as booking flow above)
---
### Comparison to Old Single Table
| Old `tblXtimeAppointmentRecord` | New Design |
|----------------------------------|-----------|
| Single table. Audit + data mixed | 3 purpose-built tables |
| `AppointmentType NOT NULL` bug | Column removed entirely |
| `AppointmentDetailJson` JSONB blob | Normalized services/packages with proper indexes |
| No scheduled time column | `ScheduledDateTimeUtc` first-class column |
| Status: Processing/Success/Failed (API status) | 5 customer-facing statuses with full lifecycle |
| No indexes | Full index coverage on all query patterns |
| Cannot report on services | `Opcode` indexed for instant reporting |
---
## SQL Migration Scripts
Add as `DatabaseService/Scripts/000021-AddAdvanceSchedulingTables.sql`:
```sql
-- ============================================================
-- Migration: 000021-AddAdvanceSchedulingTables.sql
-- Purpose: Create tables for the Advance Scheduling feature
-- ============================================================
-- TABLE 1: Main appointment record
CREATE TABLE IF NOT EXISTS "advanceschedulingappointment" (
"Id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"CustomerId" UUID NOT NULL,
"VehicleId" UUID,
"Vin" VARCHAR(50) NOT NULL,
"XtimeAppointmentId" VARCHAR(100),
"ScheduledDateTimeUtc" TIMESTAMP NOT NULL,
"DealerTimeZone" VARCHAR(60) NOT NULL,
"EstimatedDurationMinutes" INT NOT NULL DEFAULT 60,
"TransportType" VARCHAR(50),
"ValetJson" JSONB,
"Comment" TEXT,
"DmsNotes" TEXT,
"Status" VARCHAR(30) NOT NULL DEFAULT 'Booked',
"CancelBy" TEXT,
"CancelledAt" TIMESTAMP,
"CreatedAt" TIMESTAMP NOT NULL DEFAULT NOW(),
"UpdatedAt" TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_advsched_customer
ON "advanceschedulingappointment"("CustomerId");
CREATE INDEX IF NOT EXISTS idx_advsched_vin
ON "advanceschedulingappointment"("Vin");
CREATE INDEX IF NOT EXISTS idx_advsched_status
ON "advanceschedulingappointment"("Status");
CREATE INDEX IF NOT EXISTS idx_advsched_sched_utc
ON "advanceschedulingappointment"("ScheduledDateTimeUtc");
CREATE INDEX IF NOT EXISTS idx_advsched_status_sched
ON "advanceschedulingappointment"("Status", "ScheduledDateTimeUtc");
-- TABLE 2: Individual services per appointment
CREATE TABLE IF NOT EXISTS "advanceschedulingservice" (
"ServiceId" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"AppointmentId" UUID NOT NULL REFERENCES "advanceschedulingappointment"("Id"),
"ServicePackageId" UUID,
"Opcode" VARCHAR(100) NOT NULL,
"ServiceName" VARCHAR(200),
"Price" DECIMAL(10, 2),
"Comment" TEXT,
"ServiceCategoryId" VARCHAR(100),
"ServiceCategoryName" VARCHAR(200),
"CreatedAt" TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_advsvc_appointment
ON "advanceschedulingservice"("AppointmentId");
CREATE INDEX IF NOT EXISTS idx_advsvc_opcode
ON "advanceschedulingservice"("Opcode");
CREATE INDEX IF NOT EXISTS idx_advsvc_package
ON "advanceschedulingservice"("ServicePackageId");
-- TABLE 3: Service packages per appointment (multiple allowed)
CREATE TABLE IF NOT EXISTS "advanceschedulingservicepackage" (
"ServicePackageId" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"AppointmentId" UUID NOT NULL REFERENCES "advanceschedulingappointment"("Id"),
"Opcode" VARCHAR(100) NOT NULL,
"PackageName" VARCHAR(200),
"Price" DECIMAL(10, 2),
"Comment" TEXT,
"PackageServicesJson" JSONB,
"CreatedAt" TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_advpkg_appointment
ON "advanceschedulingservicepackage"("AppointmentId");
CREATE INDEX IF NOT EXISTS idx_advpkg_opcode
ON "advanceschedulingservicepackage"("Opcode");
Backend Architecture
AdvanceSchedulingController
| (HTTP layer: validation, auth, routing)
v
IAdvanceSchedulingService
| (business logic: Xtime customer fetch, UTC conversion, status check, DB+Xtime orchestration)
|---> IXtimeConnector (Singleton; all Xtime API calls; cached OAuth token)
|---> IAdvanceSchedulingDal (all PostgreSQL operations for the 3 tables + queries)
IXtimeConnector - Singleton with Token Caching
builder.Services.AddSingleton<IXtimeConnector, XtimeConnector>();
builder.Services.AddHttpClient("Xtime");
Token lifecycle:
- First call triggers async
Authorize()(no.Wait(), usesSemaphoreSlim-guarded lazy init) - Token is cached in memory with
expires_in-based expiry - Refreshed 5 minutes before expiry
- All HTTP calls use
IHttpClientFactory, no socket exhaustion
This fixes all 4 critical bugs in the current XtimeConnector simultaneously.
IXtimeConnector Interface Methods
Task<bool> Authorize();
Task<XtimeCustomerResponse> GetCustomer(string vin); // for booking PII
Task<XtimeServicesResponse> DealerService(XtimeServiceRequest request);
Task<XtimeTransportOptionsResponse> GetTransportOptions(XtimeTransportOptionsRequest request); // NEW
Task<XtimeAppointmentAvailabilitesResponse> DealerAppointmentAvailabilites(XtimeAppointmentAvailabilitesRequest request);
Task<XtimeAppointmentBookingResponse> AppointmentBooking(XtimeAppointmentBookingRequest request);
Task<XtimeAppointmentCancellationResponse> AppointmentCancellation(XtimeAppointmentCancellationRequest request);
Task<XtimeCustomerAppointmentResponse> GetCustomerAppointments(string vin); // for update flow + appointment sync
IAdvanceSchedulingDal Interface Methods
// Insert a new appointment record. Returns (statusCode, generatedId).
Task<(int, Guid?)> CreateAppointment(AdvanceSchedulingAppointment appointment);
// Bulk-insert all service rows in one SQL round-trip (standalone + package sub-services).
// No-op if the list is empty. Returns affected row count.
Task<int> CreateServices(IList<AdvanceSchedulingService> services);
// Bulk-insert all service-package rows. Returns (statusCode, list of generated UUIDs in input order).
// Caller uses these UUIDs as FKs when inserting sub-services.
Task<(int, IList<Guid>?)> CreateServicePackages(IList<AdvanceSchedulingServicePackage> packages);
// Fetch appointment by Xtime appointment ID. Returns RECORD_NOT_FOUND if missing.
Task<(int, AdvanceSchedulingAppointment?)> GetAppointmentByXtimeAppointmentId(string xtimeAppointmentId);
// Update Status, CancelBy, CancelledAt. Pass null for cancelBy/cancelledAt when not cancelling.
Task<int> UpdateAppointmentStatus(
Guid appointmentId,
AdvanceSchedulingStatus status,
UserType? cancelBy,
DateTime? cancelledAt);
// Fire-and-forget audit log for Xtime API calls. Called OUTSIDE any TransactionScope.
Task<int> LogApiCall(AdvanceSchedulingApiLog log);
// Paginated, filtered, sorted appointments with nested VehicleInfo + CustomerInfo.
// Admin: pass customerId=null, isAdmin=true (full keyword scope).
// Customer: pass resolved UUID, isAdmin=false (ownership filter + restricted keyword scope).
Task<(int status, IList<AppointmentDto>? data, int totalCount)> GetAppointmentsAsync(
AppointmentFilter filter, Guid? customerId, bool isAdmin);
Configuration
Add to .env.example, .env.local, .env.staging, .env.uat:
XTIME_AUTH_URL=https://identity.coxautoinc.com/oauth2/token
XTIME_CLIENT_ID=
XTIME_SECRET_TOKEN=
XTIME_BASE_URL=https://api.coxautoinc.com/servicescheduling/v1
XTIME_API_KEY=
XTIME_DEALER_CODE=
XTIME_AUTH_URLandXTIME_BASE_URLdiffer between sandbox and production. Get exact values from the Cox Automotive developer portal.No
XTIME_DEALER_TIMEZONEconfig key is needed. Xtime returns available slots with the UTC offset already embedded in the datetime string (e.g.,"2026-03-25T08:00-07:00"). The backend parses this offset directly withDateTimeOffset.Parse()— no IANA timezone lookup required.
Xtime Compatibility Notes
Test all of these once credentials are obtained:
| Item | Our Assumption | What to Test |
|---|---|---|
| VIN-only vehicle object | Xtime resolves VIN internally for all endpoints | Send {"vehicle": {"vin": "..."}}, confirm valid response |
| Customer fields from GET /customers | We use Xtime's customer data for booking, not our DB | Verify response shape matches our model |
advisorId omission | Can be omitted from all POST endpoints | Send requests without it |
services[].opcode field name | Transport-options uses "opcode" not "id" as the JSON property name | Confirm our [JsonPropertyName("opcode")] mapping is correct |
| Multiple service packages | Xtime servicePackage is singular in booking payload | Test with 2 packages. If rejected, restrict to 1 in service layer |
opcodeList as comma-separated string | Backend joins array into "OPC1,OPC2" for Xtime | Confirm format is correct |
| Cancel endpoint uses POST not DELETE | POST /appointments-cancellations/dealer/{dealerCode} | Confirm it works with just appointmentId in body |
Implementation Phases
| Phase | What | Key Files | Notes |
|---|---|---|---|
| 1 | Add XTIME_* env vars | .env.* files | Cannot test anything without this, do first |
| 2 | Refactor XtimeConnector -> IXtimeConnector | XtimeConnector.cs, new IXtimeConnector.cs, XtimeTransportOptionsRequest/Response.cs | Fixes all 4 critical bugs; adds GetTransportOptions() |
| 3 | DB migration | 000021-AddAdvanceSchedulingTables.sql | Creates 3 tables |
| 4 | DAL layer | IAdvanceSchedulingDal.cs, AdvanceSchedulingDal.cs, Entities.cs | All Dapper queries |
| 5 | Request/Response DTOs | Requests.cs, Responses.cs | Frontend contract models |
| 6 | Service layer | IAdvanceSchedulingService.cs, AdvanceSchedulingService.cs | Business logic, Xtime customer fetch, UTC conversion, status checks |
| 7 | Controller | AdvanceSchedulingController.cs | 7 endpoints, auth, input validation |
| 8 | Register DI | Program.cs | Services + Singleton connector |
| 9 | Swagger verification | - | Test all 7 endpoints end-to-end |
| 10 | Remove old files | XtimeController.cs, XtimeAppointmentService.cs, IXtimeAppointmentService.cs | Only after all endpoints are verified |
| 11 (future) | Audit log table | 000022-AddAdvanceSchedulingAuditLog.sql | Add when production debugging is needed |