April 3, 2026
BookingPilot is a B2B appointment scheduling platform. Clinics use it to manage patient bookings, and for months the most common feature request has been the same: "Can we add custom fields to the booking form?"
Sunrise Dental Clinic wants to collect insurance numbers. A physiotherapy practice needs a "Preferred Contact Method" dropdown. A dermatology office wants patient notes attached to every appointment. Every clinic has different requirements, and building a dynamic field system from scratch would take the team months of database schema work, validation logic, multi-tenant isolation, and a form renderer.
This walkthrough covers the full integration of Kopra into BookingPilot, from the first line of code to the final result. Not just the SDK setup, but the complete flow: backend token endpoint, frontend editor, reading values server-side for email notifications, theming, and tenant self-service.
After creating a Kopra account, BookingPilot's team creates a field group called Booking Details with the key booking-details. They add four global fields that every clinic gets by default:
| Field | Key | Type | Required |
|---|---|---|---|
| Insurance Number | insurance_number | Text | Yes |
| Service Type | service_type | Select (Cleaning, Consultation, Emergency, Follow-up) | Yes |
| Patient Notes | patient_notes | Textarea | No |
| Preferred Contact Method | preferred_contact | Select (Phone, Email, SMS) | No |
These global fields appear for every clinic. Individual clinics can then add their own tenant-specific fields on top (like "Referral Source" or "Allergies") through the self-service config panel.
The Kopra SDK needs a short-lived JWT token to authenticate. BookingPilot's Express backend generates this token by calling Kopra's token endpoint, keeping the API key server-side. The fieldLimit parameter enforces plan-based restrictions: free clinics can create up to 5 custom fields, pro clinics get 20.
// server/routes/kopra.ts
import express from 'express';
import { authenticateClinic } from '../middleware/auth';
const router = express.Router();
const KOPRA_API_URL = process.env.KOPRA_API_URL;
const KOPRA_API_KEY = process.env.KOPRA_API_KEY;
const FIELD_LIMITS: Record<string, number> = {
free: 5,
pro: 20,
};
router.post('/api/kopra-token', authenticateClinic, async (req, res) => {
const clinic = req.clinic; // set by authenticateClinic middleware
const { fieldGroupKey } = req.body;
const fieldLimit = FIELD_LIMITS[clinic.plan] ?? 5;
const response = await fetch(`${KOPRA_API_URL}/api/auth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': KOPRA_API_KEY!,
},
body: JSON.stringify({
tenantId: clinic.id,
fieldGroupKey,
fieldLimit,
}),
});
const data = await response.json();
if (!response.ok) {
return res.status(response.status).json({ error: data });
}
return res.json(data.data);
});
export default router;The tenantId is the clinic's ID from BookingPilot's own database. Kopra isolates all data per tenant automatically. Tenants are provisioned on first use, so there is no need to create them in advance.
When a patient opens their booking details page, BookingPilot renders the Kopra field editor for that specific booking. The entityId is the booking ID from BookingPilot's database.
// components/BookingCustomFields.tsx
import { useEffect, useRef } from 'react';
import { KopraSDK } from '@kopra-dev/sdk';
interface BookingCustomFieldsProps {
clinicId: string;
bookingId: string;
}
export function BookingCustomFields({ clinicId, bookingId }: BookingCustomFieldsProps) {
const sdkRef = useRef<KopraSDK | null>(null);
useEffect(() => {
const sdk = new KopraSDK({
currentTenant: clinicId,
getToken: async (req) => {
const res = await fetch('/api/kopra-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!res.ok) throw new Error(`Token request failed: ${res.status}`);
return res.json();
},
onFieldsSaved: (values) => {
console.log('Booking fields saved:', values);
},
onFieldsError: (error) => {
console.error('Field editor error:', error);
},
});
sdkRef.current = sdk;
sdk.loadCustomFields('booking-fields', {
fieldGroupKey: 'booking-details',
entityId: bookingId,
});
return () => {
sdk.destroy();
};
}, [clinicId, bookingId]);
return <div id="booking-fields" />;
}Usage in a parent component:
<BookingCustomFields clinicId="sunrise-dental" bookingId="booking-1042" />The iframe auto-resizes to fit its content. If Sunrise Dental has the four global fields plus two tenant-specific fields they created, the editor renders all six.
Autosave is enabled by default. When a clinic receptionist stops typing for 2 seconds, the values persist automatically. The embedded editor shows a brief "Changes saved" indicator inside the iframe.
The onFieldsSaved callback fires on every save, giving BookingPilot the opportunity to react. For example, updating a local cache or marking the booking as "details complete":
onFieldsSaved: (values) => {
// values: { insurance_number: "INS-4421-B", service_type: "Consultation", ... }
if (values.insurance_number && values.service_type) {
markBookingDetailsComplete(bookingId);
}
},For workflows where BookingPilot needs to validate before saving (like a multi-step form), autosave can be disabled and saves triggered programmatically:
sdk.disableAutosave();
// Later, when the user clicks "Confirm Booking":
const result = await sdk.saveFields();
if (result.success) {
submitBooking();
}When Dr. Emily Chen has her next appointment, BookingPilot sends a confirmation email that includes the custom field data. The backend reads these values using the REST API:
// server/services/bookingNotifications.ts
async function getBookingFieldValues(
clinicId: string,
fieldGroupId: string,
bookingId: string
): Promise<Record<string, unknown>> {
const response = await fetch(
`${KOPRA_API_URL}/api/tenants/${clinicId}/field-groups/${fieldGroupId}/entities/${bookingId}/values`,
{
headers: { 'X-API-Key': KOPRA_API_KEY! },
}
);
const data = await response.json();
return data.data; // { insurance_number: "INS-4421-B", service_type: "Consultation", ... }
}
async function sendBookingConfirmation(bookingId: string) {
const booking = await getBooking(bookingId); // BookingPilot's own data
const fieldValues = await getBookingFieldValues(
booking.clinicId,
booking.fieldGroupId,
bookingId
);
await sendEmail({
to: booking.patientEmail,
subject: `Booking Confirmed - ${booking.providerName}`,
body: `
Your appointment with Dr. Emily Chen is confirmed.
Date: ${booking.date}
Service: ${fieldValues.service_type}
Insurance: ${fieldValues.insurance_number}
${fieldValues.patient_notes ? `Notes: ${fieldValues.patient_notes}` : ''}
`,
});
}BookingPilot can also use the search endpoint to query across bookings. For instance, finding all bookings at Sunrise Dental where the service type is "Emergency":
curl -X GET \
"https://kopra.dev/api/kopra/field-values/search?tenantId=sunrise-dental&fieldKey=service_type&value=Emergency" \
-H "X-API-Key: kp_bookingpilot_key"BookingPilot uses a clean blue-and-white design system. The Kopra field editor runs inside an iframe, but its appearance is fully configurable through a theme object:
import { type ThemeConfig } from '@kopra-dev/sdk';
const bookingPilotTheme: ThemeConfig = {
container: {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '14px',
fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif',
},
label: {
fontSize: '13px',
fontWeight: '600',
color: '#1e3a5f',
marginBottom: '4px',
},
input: {
padding: '10px 14px',
border: '1px solid #c7d2e0',
borderRadius: '8px',
fontSize: '14px',
backgroundColor: '#f8fafc',
},
inputFocus: {
borderColor: '#2563eb',
boxShadow: '0 0 0 3px rgba(37, 99, 235, 0.12)',
outline: 'none',
},
select: {
padding: '10px 14px',
border: '1px solid #c7d2e0',
borderRadius: '8px',
fontSize: '14px',
cursor: 'pointer',
},
textarea: {
padding: '10px 14px',
border: '1px solid #c7d2e0',
borderRadius: '8px',
fontSize: '14px',
minHeight: '90px',
},
saveButton: { display: 'none' },
};
sdk.loadCustomFields('booking-fields', {
fieldGroupKey: 'booking-details',
entityId: 'booking-1042',
theme: bookingPilotTheme,
});The saveButton: { display: 'none' } hides the iframe's built-in save button since BookingPilot relies on autosave and its own "Confirm Booking" button.
The real power of Kopra is letting tenants configure their own fields. BookingPilot adds a "Customize Booking Form" section to each clinic's settings page. The loadFieldConfiguration method renders a management panel where clinic admins can add, edit, and reorder their custom fields.
// components/ClinicFieldSettings.tsx
import { useEffect } from 'react';
import { KopraSDK } from '@kopra-dev/sdk';
interface ClinicFieldSettingsProps {
clinicId: string;
plan: 'free' | 'pro';
}
export function ClinicFieldSettings({ clinicId, plan }: ClinicFieldSettingsProps) {
useEffect(() => {
const fieldLimit = plan === 'pro' ? 20 : 5;
const sdk = new KopraSDK({
currentTenant: clinicId,
getToken: async (req) => {
const res = await fetch('/api/kopra-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...req, fieldLimit }),
});
return res.json();
},
onConfigSaved: () => {
console.log('Clinic field configuration updated');
},
});
sdk.loadFieldConfiguration('clinic-config', {
fieldGroupKey: 'booking-details',
fieldLimit,
});
return () => sdk.destroy();
}, [clinicId, plan]);
return (
<div>
<h3>Customize Booking Form</h3>
<p>Add custom fields that patients fill out when booking an appointment.</p>
<div id="clinic-config" />
</div>
);
}Sunrise Dental's office manager can now add an "Allergies" text field and a "Referral Source" dropdown without filing a support ticket. The field limit (5 for free, 20 for pro) is enforced server-side through the JWT token, so upgrading is a natural upsell moment.
Here is what BookingPilot shipped and what it took:
| Metric | Value |
|---|---|
| Backend code | 1 Express route (~30 lines) |
| Frontend code | 2 React components (~80 lines total) |
| Server-side reads | 1 helper function (~15 lines) |
| Total integration code | ~125 lines of TypeScript |
| Time to integrate | Less than a day |
| Time if built from scratch | 4-8 weeks (schema design, validation, multi-tenant isolation, UI, API) |
BookingPilot's engineering cost for the integration was roughly one developer-day. Building the equivalent feature internally would have required designing a dynamic schema system, building a form renderer with validation, implementing multi-tenant data isolation, creating an admin UI for field management, and writing API endpoints for server-side access. That is a quarter-long project at minimum.
The ongoing cost is a Kopra subscription that scales with usage. For BookingPilot's 200 clinic customers generating around 15,000 bookings per month, this is a fraction of what a single engineer's time would cost to maintain a custom solution.
More importantly, BookingPilot shipped the feature within a week of the first customer request. Sunrise Dental is already using their custom fields in production, Dr. Emily Chen's confirmation emails include insurance details, and three more clinics signed up for the pro plan specifically to get more custom fields.
The code from this walkthrough is available as a working example in the Kopra documentation. To get started with your own integration, create a free account at kopra.dev and follow the steps above.