Skip to main content
Version: RK Auto

Advance Scheduling API Integration Using Xtime

Author

  • Sanket Mal

Last Updated Date

[2026-04-01]

Version History

VersionDateChangesAuthor
1.02026-03-19Initial draftSanket Mal
2.02026-03-25Align all API contracts with actual implementation; update DateTime handling to offset-embedded flow; fix API 2/3/4/5/6/7 models and JSON examplesSanket Mal
3.02026-04-01Align API 5 update documentation with implemented enhanced update flow, add frontend payload scenarios, fix AppointmentSortBy enum mismatch, and remove scheduler-specific sections from this documentSanket 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.

DimensionInstant ServiceAdvance Scheduling
Who does the workRK Auto technician (van dispatch)Dealership service center
Booking targetCustomer's current locationDealership location at a future time
BackendInternal (our own DB, vans, techs)Hybrid (own DB for appointment reads + Xtime API for booking operations)
Status trackingReal-time location + status updatesLocal 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 /appointments returns 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 frontendMeaning
nullKeep existing value
Non-null valueUpdate 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:

FieldCREATEUPDATE
lastNameRequiredMust match existing appointment
firstNameRecommended (may be required)Must match existing appointment if provided
phoneNumber (or workPhoneNumber or mobilePhoneNumber)At least ONE requiredAt least ONE must match existing
emailAddressOptional (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 EndpointHTTP MethodPurposeOur Connector Method
GET /customersGETGet customer details by VINGetCustomer(vin)
GET /appointmentsGETGet dealer appointments by VINGetCustomerAppointments(vin)
POST /services/dealer/{dealerCode}POSTGet available services + packagesDealerService(request)
POST /transportation/options/{dealerCode}POSTGet transport options with disclaimersGetTransportOptions(request) (NEW)
POST /appointments-availabilities/dealer/{dealerCode}POSTGet available time slotsDealerAppointmentAvailabilites(request)
POST /appointments-bookings/dealer/{dealerCode}POSTBook or update appointmentAppointmentBooking(request)
POST /appointments-cancellations/dealer/{dealerCode}POSTCancel appointmentAppointmentCancellation(request)
GET /year-make-models/dealer/{dealerCode}GETGet dealer year/make/modelsGetYMM()

Our API Mapping to Xtime

Our EndpointHTTPXtime EndpointXtime HTTPNotes
/service-suggestionsGETPOST /services/dealer/{dealerCode}POSTReturns services + packages + basic transport types
/transport-optionsPOSTPOST /transportation/options/{dealerCode}POSTNew method needed in connector
/available-slotsPOSTPOST /appointments-availabilities/dealer/{dealerCode}POSTAlready in connector
/appointment-book (create)POSTPOST /appointments-bookings/dealer/{dealerCode}POSTAlready in connector
/appointment-update (update)PUTPOST /appointments-bookings/dealer/{dealerCode}POSTSame endpoint; appointmentId present = update
/appointment-cancelPOSTPOST /appointments-cancellations/dealer/{dealerCode}POSTAlready 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 appointmentDateTimeAsPerXtime exactly as received — no conversion
  • Backend calls Xtime: GET /customers?vin={vin} to fetch customer PII
  • Backend forwards appointmentDateTimeAsPerXtime to Xtime as-is
  • Backend derives UTC from embedded offset, stores in DB; stores the offset string in DealerTimeZone column
  • 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} with appointmentId set (= 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 }
]
}

transportTypes here are basic indicators. Full options with disclaimers come in API 2.

Backend Logic

  1. Build XtimeServiceRequest with VIN, package=true, locale="en-US"
  2. Inject dealerCode from config
  3. Call XtimeConnector.DealerService()
  4. Strip code, advisors[], tellusMore from response
  5. 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"
}
FieldRequiredNotes
vinYesVehicle VIN
serviceOpcodesYes*At least one opcode OR servicePackageOpcode required
servicePackageOpcodeNoSingle 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 data field is the transport type value the frontend must send back in subsequent API calls.

Backend Logic

  1. Map serviceOpcodesServices[] in Xtime request (each as { opcode: "..." })
  2. Map servicePackageOpcodeservicePackage in Xtime request (as { opcode: "..." })
  3. Call XtimeConnector.GetTransportOptions()
  4. 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
}
FieldRequiredNotes
vinYesVehicle VIN
opcodeListYes*At least one opcode OR servicePackageOpcode required
servicePackageOpcodeNoSingle package opcode
transportTypeYesFrom API 2 response data field
startDate / endDateYesDate range as DateTime
valetNoOnly 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 opcodeList array into a single comma-separated string for opcode. advisorId intentionally 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. dealerUtcOffset is informational only.

When the user selects a slot to book, the frontend sends back appointmentDateTimeAsPerXtime exactly as received — no conversion.

Backend Logic

  1. Validate (VIN, at least one opcode/package, valid date range)
  2. Join opcodeList into comma-separated string for opcode field
  3. Build Xtime request (VIN-only vehicle, dates as "YYYY-MM-DD" strings)
  4. Call XtimeConnector.DealerAppointmentAvailabilites()
  5. For each returned slot: DateTimeOffset.Parse(appointmentDateTimeLocal) → extract UTC offset from result
  6. Set dealerUtcOffset from the first slot's parsed offset (informational)
  7. 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:

  1. GET /customers?vin={vin}&dealerCode={code} - Fetch customer PII from Xtime
  2. POST /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 }
]
}
}
FieldRequiredNotes
vinYesVehicle VIN
appointmentDateTimeAsPerXtimeYesExact string from available-slots response — forwarded as-is to Xtime
transportTypeYesFrom API 2 response data field
valetNoOnly when transportType = "VALET"
commentNoCustomer's note
servicesYes*At least one service or servicePackage required
servicePackageNoSingle package object (not an array)

firstName, lastName, emailAddress, phoneNumber are 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 }
]
}
}

appointmentDateTimeLocal is the exact AppointmentDateTimeAsPerXtime string 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, mobilePhoneNumber all come from the Xtime GET /customers response, 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)

  1. Extract userId from JWT
  2. Call Xtime GET /customers?vin={vin}&dealerCode={code} to get customer PII
  3. Extract firstName, lastName, workPhoneNumber, mobilePhoneNumber, emailAddress from Xtime response
  4. Forward AppointmentDateTimeAsPerXtime string directly to Xtime as appointmentDateTimeLocal — the embedded UTC offset (e.g. "-07:00") is already present, no conversion needed
  5. Parse the same string with DateTimeOffset.Parse() to derive UTC (ToUniversalTime()); store the offset portion (e.g. "-07:00") in the DealerTimeZone DB column for reference
  6. Build full XtimeAppointmentBookingRequest using Xtime's customer info
  7. Call XtimeConnector.AppointmentBooking()
  8. If Xtime fails: return error to customer, write nothing to DB
  9. If Xtime succeeds:
    • INSERT into advanceschedulingappointment (Status=Booked, XtimeAppointmentId=Xtime's appointmentId)
    • INSERT rows into advanceschedulingservice for each standalone service (ServicePackageId=NULL)
    • INSERT rows into advanceschedulingservicepackage for each package, then INSERT corresponding sub-service rows with ServicePackageId set
  10. 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:

  1. GET /appointments?vin={vin}&dealerCode={code} - Load current Xtime appointment
  2. GET /customers?vin={vin}&dealerCode={code} - Fetch customer PII (must match existing appointment)
  3. 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

Fieldnull meansNon-null value meansEmpty value means
transportTypeKeep existingUpdate transport typen/a
valetKeep existingReplace valetn/a
commentKeep existingReplace commentEmpty string is treated as update value
servicesKeep existing standalone servicesReplace all standalone services[] clears all standalone services
servicePackageKeep existing packageReplace package{ "opcode": "", "serviceName": "" } clears package

Important Validation Rules

The backend rejects the update before any Xtime call in these cases:

  1. xtimeAppointmentId is missing.
  2. newAppointmentDateTimeLocal is missing.
  3. Frontend tries to remove both services and package at the same time.
  4. Clearing package would leave the appointment with nothing booked.
  5. 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 /appointments and 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

  1. Look up appointment in our DB by xtimeAppointmentId
  2. Verify it belongs to the authenticated customer
  3. Verify status is Booked
  4. Call Xtime GET /appointments?vin={vin}&dealerCode={code} and find the current appointment by appointmentId
  5. Call Xtime GET /customers?vin={vin}&dealerCode={code} to get customer PII
  6. Resolve the effective update payload:
  • caller-provided values win
  • null means preserve existing values
  • if services is null, backend reuses existing standalone services from Xtime
  • if servicePackage is null, backend reuses existing package from Xtime
  • services: [] means clear standalone services
  • servicePackage: { "opcode": "", "serviceName": "" } means clear package
  1. Validate that the appointment is not left with neither services nor package
  2. Call Xtime update via POST /appointments-bookings/dealer/{dealerCode} with appointmentId populated
  3. If Xtime succeeds, persist local DB update:
  • update appointment header
  • replace standalone services only if services was supplied
  • replace service package only if servicePackage was 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

  1. Look up appointment in our DB by xtimeAppointmentId
  2. Verify it belongs to the authenticated customer
  3. Verify status is Booked only (cannot cancel InProgress, Completed, or already Cancelled)
  4. Call XtimeConnector.AppointmentCancellation()
  5. If Xtime fails: return error, no DB changes
  6. If Xtime succeeds:
    • UPDATE Status=CancelledByCustomer, CancelBy=Customer, CancelledAt=NOW()
  7. 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:

ParameterTypeRequiredDefaultNotes
statusesAdvanceSchedulingStatus (repeated)NoallUse repeated params: ?statuses=Booked&statuses=InProgress. Values: Booked, InProgress, Completed, CancelledByCustomer, CancelledByDealer. If omitted, returns all.
vinstringNo-Filter by vehicle VIN (exact match)
searchKeywordstringNo-Search scope depends on caller role. Admin can search customer fields, vehicle fields, services, and package fields. Customer search excludes customer PII fields.
pageNumberintNo1Page number (1-indexed)
rowsPerPageintNo20Rows per page (1-1000)
appointmentSortByAppointmentSortByNoAppointmentScheduleAtActual enum supports only AppointmentScheduleAt and AppointmentBookedAt
sortDirectionSortDirectionNoDESCASC 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

  1. Extract caller identity and role from JWT
  2. Validate filters:
  • pageNumber >= 1
  • rowsPerPage <= 1000
  1. 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" |

  1. INSERT advanceschedulingappointment (CustomerId, VIN, XtimeAppointmentId, ScheduledDateTimeUtc, ...) -> returns appointmentId (UUID)

  2. For each standalone service selected: INSERT advanceschedulingservice (AppointmentId=@appointmentId, ServicePackageId=NULL, Opcode, ServiceName, ...)

  3. 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

  1. Verify appointment exists AND belongs to customer AND Status='Booked'
  2. Xtime API succeeds
  3. UPDATE advanceschedulingappointment SET ... WHERE Id = @appointmentId
  4. DELETE FROM advanceschedulingservice WHERE AppointmentId = @appointmentId
  5. DELETE FROM advanceschedulingservicepackage WHERE AppointmentId = @appointmentId
  6. 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(), uses SemaphoreSlim-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_URL and XTIME_BASE_URL differ between sandbox and production. Get exact values from the Cox Automotive developer portal.

No XTIME_DEALER_TIMEZONE config 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 with DateTimeOffset.Parse() — no IANA timezone lookup required.


Xtime Compatibility Notes

Test all of these once credentials are obtained:

ItemOur AssumptionWhat to Test
VIN-only vehicle objectXtime resolves VIN internally for all endpointsSend {"vehicle": {"vin": "..."}}, confirm valid response
Customer fields from GET /customersWe use Xtime's customer data for booking, not our DBVerify response shape matches our model
advisorId omissionCan be omitted from all POST endpointsSend requests without it
services[].opcode field nameTransport-options uses "opcode" not "id" as the JSON property nameConfirm our [JsonPropertyName("opcode")] mapping is correct
Multiple service packagesXtime servicePackage is singular in booking payloadTest with 2 packages. If rejected, restrict to 1 in service layer
opcodeList as comma-separated stringBackend joins array into "OPC1,OPC2" for XtimeConfirm format is correct
Cancel endpoint uses POST not DELETEPOST /appointments-cancellations/dealer/{dealerCode}Confirm it works with just appointmentId in body

Implementation Phases

PhaseWhatKey FilesNotes
1Add XTIME_* env vars.env.* filesCannot test anything without this, do first
2Refactor XtimeConnector -> IXtimeConnectorXtimeConnector.cs, new IXtimeConnector.cs, XtimeTransportOptionsRequest/Response.csFixes all 4 critical bugs; adds GetTransportOptions()
3DB migration000021-AddAdvanceSchedulingTables.sqlCreates 3 tables
4DAL layerIAdvanceSchedulingDal.cs, AdvanceSchedulingDal.cs, Entities.csAll Dapper queries
5Request/Response DTOsRequests.cs, Responses.csFrontend contract models
6Service layerIAdvanceSchedulingService.cs, AdvanceSchedulingService.csBusiness logic, Xtime customer fetch, UTC conversion, status checks
7ControllerAdvanceSchedulingController.cs7 endpoints, auth, input validation
8Register DIProgram.csServices + Singleton connector
9Swagger verification-Test all 7 endpoints end-to-end
10Remove old filesXtimeController.cs, XtimeAppointmentService.cs, IXtimeAppointmentService.csOnly after all endpoints are verified
11 (future)Audit log table000022-AddAdvanceSchedulingAuditLog.sqlAdd when production debugging is needed