Skip to main content

Building a Booking System with Cal.com and PayPal

#astro #typescript #cal.com #paypal #supabase

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:

  1. Pick an event type and time slot
  2. We reserve the slot on Cal.com (but don’t confirm yet)
  3. Complete PayPal checkout
  4. Webhook fires, payment confirmed, booking locked in
  5. 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

  1. Always verify webhooks. Seriously. PayPal’s verification is verbose but just do it.

  2. State machines are your friend. The booking_status enum keeps things predictable.

  3. Enforce your timeouts. The 24-hour deadline isn’t just nice UX - it stops slot squatting.

  4. Use the sandbox. PayPal’s sandbox is basically identical to production. Test there first.

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