Building a Booking Widget
This guide walks you through building a complete appointment booking widget using the Jump EHR API. You'll learn the hold-and-confirm pattern that prevents double-bookings while providing a smooth user experience.
Overview
The booking flow consists of these steps:
- Select service - User chooses an appointment type
- View availability - Display available slots with optional filters
- Hold the slot - Temporarily reserve the slot (15 minutes)
- Collect information - Gather patient details while hold is active
- Confirm booking - Convert the hold into an appointment
The hold-and-confirm pattern ensures slots aren't double-booked while patients complete their information.
Required API Scopes
Your API key needs these scopes:
| Scope | Purpose |
|---|---|
read_appointment_types | List available services |
read_availability | Query available slots |
write_holds | Create and confirm holds |
read_patients | Search existing patients |
write_patients | Create new patients |
Step 1: Fetch Appointment Types
First, load the available appointment types to let users select a service.
const API_BASE = 'https://app.usejump.co.uk/functions/v1/api-v1';
async function getAppointmentTypes() {
const response = await fetch(`${API_BASE}/appointment-types`, {
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json'
}
});
const { data } = await response.json();
// Filter to only show bookable types
return data.filter(type => type.is_active && type.allow_online_booking);
}Display the Service Selector
function ServiceSelector({ onSelect }) {
const [types, setTypes] = useState([]);
useEffect(() => {
getAppointmentTypes().then(setTypes);
}, []);
return (
<div className="service-grid">
{types.map(type => (
<button key={type.id} onClick={() => onSelect(type)}>
<h3>{type.name}</h3>
<p>{type.duration_minutes} minutes</p>
<p>£{type.price.toFixed(2)}</p>
</button>
))}
</div>
);
}Step 2: Query Availability
Once the user selects a service, fetch available slots.
async function getAvailability(appointmentTypeId, startDate, endDate, filters = {}) {
const params = new URLSearchParams({
appointment_type_id: appointmentTypeId,
start_date: startDate, // YYYY-MM-DD
end_date: endDate // YYYY-MM-DD
});
// Optional filters
if (filters.clinicianId) {
params.append('clinician_id', filters.clinicianId);
}
if (filters.locationId) {
params.append('location_id', filters.locationId);
}
if (filters.isRemote !== undefined) {
params.append('is_remote', filters.isRemote);
}
const response = await fetch(`${API_BASE}/availability?${params}`, {
headers: { 'Authorization': `Bearer ${API_KEY}` }
});
const { data } = await response.json();
return data;
}Build an Availability Calendar
Group slots by date for a calendar view:
function groupSlotsByDate(slots) {
return slots.reduce((acc, slot) => {
const date = slot.start_time.split('T')[0];
if (!acc[date]) {
acc[date] = [];
}
acc[date].push(slot);
return acc;
}, {});
}function AvailabilityCalendar({ appointmentTypeId, onSelectSlot }) {
const [slots, setSlots] = useState([]);
const [selectedDate, setSelectedDate] = useState(null);
const [filters, setFilters] = useState({});
useEffect(() => {
const startDate = new Date().toISOString().split('T')[0];
const endDate = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000)
.toISOString().split('T')[0];
getAvailability(appointmentTypeId, startDate, endDate, filters)
.then(setSlots);
}, [appointmentTypeId, filters]);
const slotsByDate = groupSlotsByDate(slots);
const dates = Object.keys(slotsByDate).sort();
return (
<div>
{/* Filter controls */}
<div className="filters">
<label>
<input
type="checkbox"
checked={filters.isRemote === true}
onChange={(e) => setFilters({
...filters,
isRemote: e.target.checked ? true : undefined
})}
/>
Video appointments only
</label>
</div>
{/* Date picker */}
<div className="date-selector">
{dates.map(date => (
<button
key={date}
className={selectedDate === date ? 'selected' : ''}
onClick={() => setSelectedDate(date)}
>
{formatDate(date)}
<span className="slot-count">
{slotsByDate[date].length} slots
</span>
</button>
))}
</div>
{/* Time slots for selected date */}
{selectedDate && (
<div className="time-slots">
{slotsByDate[selectedDate].map(slot => (
<button
key={`${slot.start_time}-${slot.clinician_id}`}
onClick={() => onSelectSlot(slot)}
>
{formatTime(slot.start_time)}
<span className="clinician">{slot.clinician_name}</span>
{slot.is_remote && <span className="badge">Video</span>}
</button>
))}
</div>
)}
</div>
);
}Step 3: Create a Hold
When the user selects a slot, create a hold to reserve it. This prevents others from booking the same slot while your user completes their information.
async function createHold(slot, appointmentTypeId) {
const date = slot.start_time.split('T')[0];
const time = slot.start_time.split('T')[1].substring(0, 5); // HH:MM
const response = await fetch(`${API_BASE}/holds`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
appointment_type_id: appointmentTypeId,
clinician_id: slot.clinician_id,
location_id: slot.location_id,
date: date,
start_time: time
})
});
if (response.status === 409) {
throw new Error('SLOT_TAKEN');
}
if (!response.ok) {
throw new Error('Failed to create hold');
}
const { data } = await response.json();
return data;
}Handle Hold Creation
async function handleSlotSelect(slot) {
try {
const hold = await createHold(slot, selectedAppointmentType.id);
setActiveHold(hold);
setStep('collect-info');
} catch (error) {
if (error.message === 'SLOT_TAKEN') {
alert('Sorry, this slot was just taken. Please select another.');
refreshAvailability();
} else {
alert('Something went wrong. Please try again.');
}
}
}Step 4: Display Countdown Timer
Holds expire after 15 minutes. Show users how much time they have left.
Always display a countdown timer when a hold is active. Users need to know they're on a deadline.
function HoldTimer({ expiresAt, onExpired }) {
const [timeLeft, setTimeLeft] = useState(null);
useEffect(() => {
const calculateTimeLeft = () => {
const expires = new Date(expiresAt).getTime();
const now = Date.now();
const diff = Math.max(0, expires - now);
return Math.floor(diff / 1000);
};
setTimeLeft(calculateTimeLeft());
const interval = setInterval(() => {
const remaining = calculateTimeLeft();
setTimeLeft(remaining);
if (remaining <= 0) {
clearInterval(interval);
onExpired();
}
}, 1000);
return () => clearInterval(interval);
}, [expiresAt, onExpired]);
if (timeLeft === null) return null;
const minutes = Math.floor(timeLeft / 60);
const seconds = timeLeft % 60;
return (
<div className={`timer ${timeLeft < 120 ? 'warning' : ''}`}>
<span>Complete booking in: </span>
<strong>
{minutes}:{seconds.toString().padStart(2, '0')}
</strong>
</div>
);
}Handle Expiration
function handleHoldExpired() {
setActiveHold(null);
setStep('select-slot');
alert('Your reservation has expired. Please select a new time slot.');
}Step 5: Collect Patient Information
While the hold is active, collect the patient's details.
Check for Existing Patient
Search for existing patients to avoid duplicates:
async function searchPatients(query) {
const response = await fetch(
`${API_BASE}/patients?search=${encodeURIComponent(query)}&limit=5`,
{ headers: { 'Authorization': `Bearer ${API_KEY}` } }
);
const { data } = await response.json();
return data;
}Create New Patient if Needed
async function createPatient(patientData) {
const response = await fetch(`${API_BASE}/patients`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
first_name: patientData.firstName,
last_name: patientData.lastName,
date_of_birth: patientData.dateOfBirth,
email: patientData.email,
phone: patientData.phone
})
});
const { data } = await response.json();
return data;
}Patient Information Form
function PatientForm({ onSubmit }) {
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
dateOfBirth: '',
email: '',
phone: ''
});
const [existingPatients, setExistingPatients] = useState([]);
const [selectedPatient, setSelectedPatient] = useState(null);
// Search as user types
const handleNameChange = async (firstName, lastName) => {
setFormData({ ...formData, firstName, lastName });
if (firstName.length >= 2 && lastName.length >= 2) {
const results = await searchPatients(`${firstName} ${lastName}`);
setExistingPatients(results);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
if (selectedPatient) {
onSubmit(selectedPatient.id, formData);
} else {
const newPatient = await createPatient(formData);
onSubmit(newPatient.id, formData);
}
};
return (
<form onSubmit={handleSubmit}>
<div className="form-row">
<input
type="text"
placeholder="First name"
value={formData.firstName}
onChange={(e) => handleNameChange(e.target.value, formData.lastName)}
required
/>
<input
type="text"
placeholder="Last name"
value={formData.lastName}
onChange={(e) => handleNameChange(formData.firstName, e.target.value)}
required
/>
</div>
{/* Show existing patient matches */}
{existingPatients.length > 0 && !selectedPatient && (
<div className="existing-patients">
<p>Is this you?</p>
{existingPatients.map(patient => (
<button
key={patient.id}
type="button"
onClick={() => setSelectedPatient(patient)}
>
{patient.first_name} {patient.last_name}
<span>{patient.date_of_birth}</span>
</button>
))}
</div>
)}
{!selectedPatient && (
<>
<input
type="date"
value={formData.dateOfBirth}
onChange={(e) => setFormData({ ...formData, dateOfBirth: e.target.value })}
required
/>
<input
type="email"
placeholder="Email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
<input
type="tel"
placeholder="Phone"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
/>
</>
)}
<button type="submit">Continue to Confirmation</button>
</form>
);
}Step 6: Confirm the Hold
Convert the hold into an actual appointment.
async function confirmHold(holdId, patientId, attendeeInfo) {
const response = await fetch(`${API_BASE}/holds/${holdId}/confirm`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
patient_id: patientId,
attendee_name: `${attendeeInfo.firstName} ${attendeeInfo.lastName}`,
attendee_email: attendeeInfo.email,
attendee_phone: attendeeInfo.phone
})
});
if (response.status === 404) {
throw new Error('HOLD_EXPIRED');
}
if (!response.ok) {
throw new Error('Failed to confirm booking');
}
const { data } = await response.json();
return data;
}Handle Confirmation
async function handleConfirm(patientId, attendeeInfo) {
try {
const appointment = await confirmHold(
activeHold.id,
patientId,
attendeeInfo
);
setBookedAppointment(appointment);
setStep('success');
} catch (error) {
if (error.message === 'HOLD_EXPIRED') {
alert('Sorry, your reservation expired. Please select a new time.');
setActiveHold(null);
setStep('select-slot');
} else {
alert('Failed to complete booking. Please try again.');
}
}
}Error Handling
Common Errors
| Error | Cause | Recovery |
|---|---|---|
409 on create hold | Slot already taken | Refresh availability, prompt new selection |
404 on confirm | Hold expired | Return to slot selection |
429 | Rate limited | Show retry message with delay |
401 | Invalid API key | Check configuration |
Implement Retry Logic
async function fetchWithRetry(url, options, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(url, options);
if (response.status === 429) {
const delay = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
return response;
}
throw new Error('Max retries exceeded');
}Complete Example
Here's the full booking widget component:
function BookingWidget() {
const [step, setStep] = useState('select-service');
const [selectedType, setSelectedType] = useState(null);
const [selectedSlot, setSelectedSlot] = useState(null);
const [activeHold, setActiveHold] = useState(null);
const [bookedAppointment, setBookedAppointment] = useState(null);
const handleServiceSelect = (type) => {
setSelectedType(type);
setStep('select-slot');
};
const handleSlotSelect = async (slot) => {
try {
const hold = await createHold(slot, selectedType.id);
setSelectedSlot(slot);
setActiveHold(hold);
setStep('collect-info');
} catch (error) {
if (error.message === 'SLOT_TAKEN') {
alert('This slot was just taken. Please select another.');
}
}
};
const handleHoldExpired = () => {
setActiveHold(null);
setStep('select-slot');
alert('Your reservation expired. Please select a new time.');
};
const handlePatientSubmit = async (patientId, info) => {
try {
const appointment = await confirmHold(activeHold.id, patientId, info);
setBookedAppointment(appointment);
setStep('success');
} catch (error) {
if (error.message === 'HOLD_EXPIRED') {
handleHoldExpired();
}
}
};
return (
<div className="booking-widget">
{/* Progress indicator */}
<ProgressSteps current={step} />
{step === 'select-service' && (
<ServiceSelector onSelect={handleServiceSelect} />
)}
{step === 'select-slot' && (
<AvailabilityCalendar
appointmentTypeId={selectedType.id}
onSelectSlot={handleSlotSelect}
/>
)}
{step === 'collect-info' && (
<>
<HoldTimer
expiresAt={activeHold.expires_at}
onExpired={handleHoldExpired}
/>
<BookingSummary slot={selectedSlot} type={selectedType} />
<PatientForm onSubmit={handlePatientSubmit} />
</>
)}
{step === 'success' && (
<SuccessMessage appointment={bookedAppointment} />
)}
</div>
);
}Best Practices
Do:
- Always show a countdown timer during the hold phase
- Search for existing patients before creating duplicates
- Handle slot conflicts gracefully with clear messaging
- Pre-load appointment types to reduce initial load time
Don't:
- Create holds until the user actively selects a slot
- Let holds expire silently without notifying the user
- Skip patient search and always create new records
- Ignore the
expires_attimestamp from the hold response
Next Steps
- Set up Webhooks to receive booking notifications
- Learn about Patient Sync for managing patient records
- Explore the API Reference for all endpoints