API Documentation is in beta. Report issues to developers@jump.health
Building a Booking Widget

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:

  1. Select service - User chooses an appointment type
  2. View availability - Display available slots with optional filters
  3. Hold the slot - Temporarily reserve the slot (15 minutes)
  4. Collect information - Gather patient details while hold is active
  5. 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:

ScopePurpose
read_appointment_typesList available services
read_availabilityQuery available slots
write_holdsCreate and confirm holds
read_patientsSearch existing patients
write_patientsCreate 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

ErrorCauseRecovery
409 on create holdSlot already takenRefresh availability, prompt new selection
404 on confirmHold expiredReturn to slot selection
429Rate limitedShow retry message with delay
401Invalid API keyCheck 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_at timestamp from the hold response

Next Steps