I wanted to add paid consultations to my portfolio. Simple stuff - pick a time, pay, done. Turns out “simple” is never simple.
Why Not Just Use Calendly?
Most scheduling tools either force you into their payment system or take a cut. Nope.
I wanted:
- Calendar scheduling without building one from scratch (I’m not insane)
- PayPal because Stripe has regional headaches
- Full control over the UX
- Auto-cleanup when people ghost the payment
Cal.com does the calendar stuff. PayPal takes the money. I just had to glue them together. How hard could it be?
How It Works
The flow is pretty straightforward:
- Pick an event type and time slot
- We reserve the slot on Cal.com (but don’t confirm yet)
- Complete PayPal checkout
- Webhook fires, payment confirmed, booking locked in
- Everyone gets emails
What if someone picks a slot and never pays? Cron job cleans it up. No slot squatters.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Client │────▶│ Astro │────▶│ Cal.com │
│ (React) │ │ API │ │ API │
└─────────────┘ └──────┬──────┘ └─────────────┘
│
┌─────▼─────┐
│ Supabase │
│ (state) │
└─────┬─────┘
│
┌─────▼─────┐
│ PayPal │
│ Webhooks │
└───────────┘
The Stack
- Astro 5 + React islands
- Supabase for booking state
- Cal.com API v2 for scheduling
- PayPal REST API for payments
- Resend for emails
Database Schema
Bookings go through multiple states, so we need to track that:
create type booking_status as enum (
'PENDING_PAYMENT',
'CONFIRMED',
'CANCELLED',
'EXPIRED'
);
create table bookings (
id uuid primary key default gen_random_uuid(),
cal_booking_id integer,
cal_booking_uid text,
paypal_order_id text,
-- User info
name text not null,
email text not null,
-- Event details
event_type text not null,
start_time timestamptz not null,
end_time timestamptz not null,
price decimal(10,2) not null,
-- State
status booking_status default 'PENDING_PAYMENT',
payment_deadline timestamptz,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
The payment_deadline field is the key. 24 hours to pay or the cron job comes for you.
Cal.com API
Heads up - Cal.com’s docs are… rough. But the product is solid so I stuck with v2 anyway.
One gotcha: v2 uses date-based versioning in headers, not the URL. Miss this and you’ll get weird responses:
const response = await fetch(
`https://api.cal.com/v2/bookings`,
{
headers: {
'cal-api-version': '2024-08-13',
'Authorization': `Bearer ${API_KEY}`,
},
}
);
Getting Available Slots
// lib/calcom.ts
export async function getAvailability(
eventTypeId: number,
startTime: string,
endTime: string
) {
const response = await fetch(
`https://api.cal.com/v2/slots/available?` +
`startTime=${startTime}&` +
`endTime=${endTime}&` +
`eventTypeId=${eventTypeId}`,
{
headers: {
'cal-api-version': '2024-08-13',
'Authorization': `Bearer ${CALCOM_API_KEY}`,
},
}
);
return response.json();
}
Creating a Booking
export async function createBooking(data: {
eventTypeId: number;
start: string;
name: string;
email: string;
timeZone: string;
}) {
const response = await fetch(
`https://api.cal.com/v2/bookings`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'cal-api-version': '2024-08-13',
'Authorization': `Bearer ${CALCOM_API_KEY}`,
},
body: JSON.stringify({
eventTypeId: data.eventTypeId,
start: data.start,
attendee: {
name: data.name,
email: data.email,
timeZone: data.timeZone,
},
language: 'en',
}),
}
);
return response.json();
}
We create the Cal.com booking right away but mark it PENDING_PAYMENT in our database. No payment? We cancel it later.
PayPal Integration
PayPal’s API is… fine. It works.
Creating an Order
// lib/paypal.ts
export async function createOrder(booking: Booking) {
const auth = await getAccessToken();
const response = await fetch(
`${PAYPAL_API}/v2/checkout/orders`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${auth.access_token}`,
},
body: JSON.stringify({
intent: 'CAPTURE',
purchase_units: [{
amount: {
currency_code: 'USD',
value: booking.price.toString(),
},
description: `${booking.event_type} consultation`,
}],
application_context: {
return_url: `${SITE_URL}/book/success?id=${booking.id}`,
cancel_url: `${SITE_URL}/book/cancel?id=${booking.id}`,
},
}),
}
);
return response.json();
}
Webhook Verification
This part matters. Never trust a webhook you haven’t verified:
// pages/api/webhooks/paypal.ts
export async function POST({ request }: APIContext) {
const body = await request.text();
const headers = Object.fromEntries(request.headers);
// Verify with PayPal - don't skip this
const isValid = await verifyWebhookSignature({
auth_algo: headers['paypal-auth-algo'],
cert_url: headers['paypal-cert-url'],
transmission_id: headers['paypal-transmission-id'],
transmission_sig: headers['paypal-transmission-sig'],
transmission_time: headers['paypal-transmission-time'],
webhook_id: PAYPAL_WEBHOOK_ID,
webhook_event: JSON.parse(body),
});
if (!isValid) {
return new Response('Invalid signature', { status: 401 });
}
const event = JSON.parse(body);
if (event.event_type === 'CHECKOUT.ORDER.APPROVED') {
await captureOrder(event.resource.id);
}
if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') {
await updateBookingStatus(
event.resource.custom_id,
'CONFIRMED'
);
await sendConfirmationEmails(event.resource.custom_id);
}
return new Response('OK');
}
The Cron Job
Someone picks a slot, gets distracted, never pays. Now what? That slot is stuck.
A daily cron job fixes this:
// lib/booking-cron.ts
export async function expireUnpaidBookings() {
const { data: expired } = await supabase
.from('bookings')
.select('*')
.eq('status', 'PENDING_PAYMENT')
.lt('payment_deadline', new Date().toISOString());
for (const booking of expired ?? []) {
if (booking.cal_booking_uid) {
await cancelCalBooking(booking.cal_booking_uid);
}
await supabase
.from('bookings')
.update({ status: 'EXPIRED' })
.eq('id', booking.id);
await sendExpirationEmail(booking);
}
return { expired: expired?.length ?? 0 };
}
Runs inside Docker with a simple script:
#!/bin/bash
curl -X POST "http://localhost:4321/api/cron/expire-bookings" \
-H "Authorization: Bearer $CRON_SECRET"
The Calendar UI
React islands for the interactive bits:
// components/booking/CalendarPicker.tsx
export function CalendarPicker({
eventTypeId,
onSlotSelect
}: Props) {
const [selectedDate, setSelectedDate] = useState<Date>();
const [slots, setSlots] = useState<Slot[]>([]);
useEffect(() => {
if (!selectedDate) return;
fetch(`/api/availability?` +
`eventTypeId=${eventTypeId}&` +
`date=${selectedDate.toISOString()}`
)
.then(res => res.json())
.then(setSlots);
}, [selectedDate, eventTypeId]);
return (
<div className="grid grid-cols-2 gap-8">
<Calendar
selected={selectedDate}
onSelect={setSelectedDate}
disabled={(date) => date < new Date()}
/>
<div className="space-y-2">
{slots.map((slot) => (
<button
key={slot.time}
onClick={() => onSlotSelect(slot)}
className="w-full p-3 text-left border rounded-lg
hover:border-orange-500 transition-colors"
>
{format(new Date(slot.time), 'h:mm a')}
</button>
))}
</div>
</div>
);
}
What I Learned
-
Always verify webhooks. Seriously. PayPal’s verification is verbose but just do it.
-
State machines are your friend. The
booking_statusenum keeps things predictable. -
Enforce your timeouts. The 24-hour deadline isn’t just nice UX - it stops slot squatting.
-
Use the sandbox. PayPal’s sandbox is basically identical to production. Test there first.
-
When docs suck, read the source. Cal.com is open-source. Their GitHub answered questions their docs couldn’t.
What’s Next
A few things on the list:
- Stripe as a fallback for card payments
- Rescheduling flow
- Maybe recurring bookings someday
For now it handles single bookings well. Good enough to ship.
Questions? Get in touch.