NobGit
public imalexnord read

Slot

Calendar-first scheduling powered by Cloudflare Workers and Google Calendar.

Languages

Repository composition by tracked source files.

TypeScript
TypeScript 79% CSS 19% SQL 2% HTML 0%
Create file Wiki Documentation
Clone
https://nobgit.com/user/imalexnord/slot.git
ssh://[email protected]:2222/user/imalexnord/slot.git

Commit

Added Stripe payments

9abcc27
Alex Nord <[email protected]> 34 hours ago
.dev.vars.example                   |   2 +
 ARCHITECTURE.md                     |  16 ++-
 README.md                           |  52 ++++++++-
 migrations/0003_stripe_payments.sql |  11 ++
 package.json                        |   1 +
 scripts/setup-stripe.mjs            | 111 +++++++++++++++++++
 scripts/setup.mjs                   |  20 +++-
 src/App.tsx                         | 148 ++++++++++++++++++++++++-
 src/styles.css                      |  49 +++++++++
 worker/index.ts                     | 211 +++++++++++++++++++++++++++++++++++-
 worker/settings.ts                  |   4 +
 worker/stripe.ts                    | 165 ++++++++++++++++++++++++++++
 worker/types.ts                     |   6 +
 wrangler.jsonc                      |   8 +-
 14 files changed, 787 insertions(+), 17 deletions(-)
 create mode 100644 migrations/0003_stripe_payments.sql
 create mode 100644 scripts/setup-stripe.mjs
 create mode 100644 worker/stripe.ts

Diff

diff --git a/.dev.vars.example b/.dev.vars.example
index f5f880b..906559d 100644
--- a/.dev.vars.example
+++ b/.dev.vars.example
@@ -4,3 +4,5 @@ GOOGLE_CLIENT_ID=your-client.apps.googleusercontent.com
 GOOGLE_CLIENT_SECRET=your-google-client-secret
 SESSION_SECRET=generate-a-long-random-secret
 TOKEN_ENCRYPTION_KEY=base64-encoded-32-byte-key
+STRIPE_SECRET_KEY=sk_test_your-stripe-secret-key
+STRIPE_WEBHOOK_SECRET=whsec_your-stripe-webhook-secret
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 352a560..3664910 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -17,12 +17,17 @@ your-booking-domain.example
              │    OAuth state
              │    short-lived slot locks
              │    basic rate limits
+             │    Stripe payment state
              │
-             └── Google Calendar API
-                  FreeBusy
-                  Event creation
-                  Google Meet creation
-                  attendee invitation
+             ├── Google Calendar API
+             │    FreeBusy
+             │    Event creation
+             │    Google Meet creation
+             │    attendee invitation
+             │
+             └── Stripe API
+                  Checkout Sessions
+                  signed webhooks
 ```
 
 ## Booking transaction
@@ -36,6 +41,7 @@ your-booking-domain.example
 7. Google Calendar event is created, with a unique Meet conference when selected.
 8. Booking is marked confirmed.
 9. D1 safety locks expire after ten minutes, after which Google Calendar is again the source of truth. This means manually moving/deleting the event in Google eventually frees the original time without maintaining a second calendar system.
+10. If optional payment is enabled, the confirmed visitor can open Stripe Checkout. Payment success or failure is tracked separately from booking status, so Stripe does not become part of the calendar transaction.
 
 ## Why D1 exists
 
diff --git a/README.md b/README.md
index 94d80ee..6d8d26a 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ A small calendar-first booking app built as one Cloudflare Worker deployment.
 
 The visitor flow is intentionally simple:
 
-**date → time → duration → details → booked**
+**date → time → duration → details → booked → optional payment**
 
 There are no public accounts and no Google login for visitors. Google OAuth is private to the calendar owner.
 
@@ -17,6 +17,7 @@ There are no public accounts and no Google login for visitors. Google OAuth is p
 - Google Calendar FreeBusy for live availability
 - Google Calendar Events API for invites
 - Google Meet generated on the event when enabled
+- Optional Stripe Checkout payments through the Stripe API
 
 No GitHub Actions, Pages Functions, KV, Microsoft OAuth, external email service, or separately hosted backend.
 
@@ -34,6 +35,8 @@ No GitHub Actions, Pages Functions, KV, Microsoft OAuth, external email service,
 - Creates the Google Calendar event and emails the attendee through Google Calendar
 - Private `/admin` UI authenticated through the configured Google account
 - Stores the Google refresh token encrypted at rest in D1 using an AES-GCM key kept as a Worker secret
+- Keeps payment optional: the calendar booking is confirmed first, so Stripe failures or cancelled Checkout sessions do not destroy the reservation
+- Verifies Stripe webhook signatures before recording payment state
 
 ## First setup
 
@@ -147,6 +150,53 @@ https://book.example.com/admin
 
 Sign in with the email entered during setup. The first authorization requests offline Calendar access so the Worker can check availability and create bookings while you are not present.
 
+## Optional Stripe payments
+
+Stripe is deliberately optional. The existing booking flow works without any Stripe configuration.
+
+This integration uses Stripe-hosted Checkout, so there is no publishable key or card form in the React app. The Stripe secret key stays in the Worker only.
+
+1. In Stripe, create or use the account that should receive booking payments.
+2. Add the Stripe secret key to the Worker:
+
+```bash
+npx wrangler secret put STRIPE_SECRET_KEY
+```
+
+3. Create a Stripe webhook endpoint pointing to:
+
+```text
+https://book.example.com/api/stripe/webhook
+```
+
+Subscribe it to these Checkout events:
+
+```text
+checkout.session.completed
+checkout.session.async_payment_succeeded
+checkout.session.async_payment_failed
+checkout.session.expired
+```
+
+4. Copy the webhook signing secret (it starts with `whsec_`) into the Worker:
+
+```bash
+npx wrangler secret put STRIPE_WEBHOOK_SECRET
+```
+
+5. Apply the payment migration and deploy:
+
+```bash
+npm run db:migrate:remote
+npm run deploy
+```
+
+6. Open `/admin` → **Settings** → **Stripe payments**. Choose the currency, amount in that currency's smallest unit, and checkout label, then enable **Offer optional payment**.
+
+For example, `5000` with `usd` is `$50.00`. Stripe Checkout opens only when the person chooses to pay. Cancelling Checkout does not cancel the calendar booking.
+
+Payment status is stored on the booking and shown in the admin booking details. Cancelling a paid booking does **not** automatically issue a refund; handle refunds in Stripe so a calendar cancellation cannot unexpectedly move money.
+
 ### Google project in Testing mode
 
 If the Google OAuth audience is **External / Testing**, add the admin account as a test user. Google may expire testing-mode authorizations, so move the OAuth app to the appropriate production state when you are satisfied with it.
diff --git a/migrations/0003_stripe_payments.sql b/migrations/0003_stripe_payments.sql
new file mode 100644
index 0000000..9ccee8c
--- /dev/null
+++ b/migrations/0003_stripe_payments.sql
@@ -0,0 +1,11 @@
+ALTER TABLE bookings ADD COLUMN payment_status TEXT NOT NULL DEFAULT 'not_requested';
+ALTER TABLE bookings ADD COLUMN payment_amount INTEGER;
+ALTER TABLE bookings ADD COLUMN payment_currency TEXT;
+ALTER TABLE bookings ADD COLUMN stripe_checkout_session_id TEXT;
+ALTER TABLE bookings ADD COLUMN stripe_payment_intent_id TEXT;
+ALTER TABLE bookings ADD COLUMN paid_at TEXT;
+
+CREATE INDEX IF NOT EXISTS idx_bookings_payment_status ON bookings(payment_status);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_bookings_stripe_checkout_session_id
+  ON bookings(stripe_checkout_session_id)
+  WHERE stripe_checkout_session_id IS NOT NULL;
diff --git a/package.json b/package.json
index 014a6d6..aafa595 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
     "preview": "vite preview",
     "deploy": "vite build && wrangler deploy",
     "setup": "node scripts/setup.mjs",
+    "setup:stripe": "node scripts/setup-stripe.mjs",
     "db:migrate:local": "wrangler d1 migrations apply DB --local",
     "db:migrate:remote": "wrangler d1 migrations apply DB --remote",
     "typecheck": "tsc --noEmit"
diff --git a/scripts/setup-stripe.mjs b/scripts/setup-stripe.mjs
new file mode 100644
index 0000000..99cf44e
--- /dev/null
+++ b/scripts/setup-stripe.mjs
@@ -0,0 +1,111 @@
+import { spawnSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import { stdin as input, stdout as output } from 'node:process';
+import { createInterface } from 'node:readline/promises';
+
+const root = process.cwd();
+const workerConfigPath = path.join(root, 'wrangler.jsonc');
+const wranglerCli = path.join(root, 'node_modules', 'wrangler', 'bin', 'wrangler.js');
+
+function fail(message) {
+  console.error(`\nStripe setup failed: ${message}`);
+  process.exit(1);
+}
+
+function ensureProjectRoot() {
+  if (!fs.existsSync(workerConfigPath)) {
+    fail('Run npm run setup:stripe from the Slot project root.');
+  }
+  if (!fs.existsSync(wranglerCli)) {
+    fail('Dependencies are missing. Run npm install, then try again.');
+  }
+}
+
+function wrangler(args, options = {}) {
+  const { allowFailure = false, capture = false } = options;
+  const result = spawnSync(process.execPath, [wranglerCli, ...args], {
+    cwd: root,
+    shell: false,
+    stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
+    encoding: capture ? 'utf8' : undefined,
+    env: { ...process.env, NO_COLOR: process.env.NO_COLOR ?? '1' },
+  });
+
+  if (result.error) {
+    if (allowFailure) return result;
+    throw result.error;
+  }
+  if ((result.status ?? 1) !== 0 && !allowFailure) {
+    process.exit(result.status ?? 1);
+  }
+  return result;
+}
+
+async function promptForStripe() {
+  const rl = createInterface({ input, output });
+  try {
+    console.log('\nStripe configuration');
+    console.log('Use test-mode values while testing. Do not paste these secrets into chat.\n');
+    const stripeSecretKey = (await rl.question('Stripe Secret Key (sk_test_... or sk_live_...): ')).trim();
+    const stripeWebhookSecret = (await rl.question('Stripe Webhook Signing Secret (whsec_...): ')).trim();
+
+    if (!/^sk_(test|live)_/.test(stripeSecretKey)) {
+      throw new Error('Stripe Secret Key must begin with sk_test_ or sk_live_.');
+    }
+    if (!/^whsec_/.test(stripeWebhookSecret)) {
+      throw new Error('Stripe Webhook Signing Secret must begin with whsec_.');
+    }
+
+    return { stripeSecretKey, stripeWebhookSecret };
+  } finally {
+    rl.close();
+  }
+}
+
+function uploadStripeSecrets(answers) {
+  const secretFile = path.join(root, `.slot-stripe-secrets-${process.pid}.json`);
+  try {
+    fs.writeFileSync(secretFile, JSON.stringify({
+      STRIPE_SECRET_KEY: answers.stripeSecretKey,
+      STRIPE_WEBHOOK_SECRET: answers.stripeWebhookSecret,
+    }), { mode: 0o600 });
+
+    wrangler(['secret', 'bulk', secretFile]);
+  } finally {
+    fs.rmSync(secretFile, { force: true });
+  }
+}
+
+function listSecretNames() {
+  const result = wrangler(['secret', 'list', '--format', 'json'], { allowFailure: true, capture: true });
+  if ((result.status ?? 1) !== 0) return new Set();
+  try {
+    const parsed = JSON.parse(`${result.stdout ?? ''}`.trim() || '[]');
+    return new Set(Array.isArray(parsed) ? parsed.map((item) => item.name).filter(Boolean) : []);
+  } catch {
+    return new Set();
+  }
+}
+
+ensureProjectRoot();
+
+console.log('\nSlot Stripe setup\n=================');
+console.log('This only adds/updates Stripe Worker secrets. It does not touch D1, Google OAuth, or application code.\n');
+
+if ((wrangler(['whoami'], { allowFailure: true, capture: true }).status ?? 1) !== 0) {
+  console.log('Opening Cloudflare login...');
+  wrangler(['login']);
+}
+
+const answers = await promptForStripe();
+uploadStripeSecrets(answers);
+
+const names = listSecretNames();
+const missing = ['STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET'].filter((name) => !names.has(name));
+if (missing.length) {
+  fail(`Could not verify Worker secrets: ${missing.join(', ')}`);
+}
+
+console.log('\nDone. Stripe secrets are configured on the existing Worker.');
+console.log('Next, enable optional payments in Slot Admin -> Settings -> Stripe payments.\n');
diff --git a/scripts/setup.mjs b/scripts/setup.mjs
index 95d2c41..beed4e3 100644
--- a/scripts/setup.mjs
+++ b/scripts/setup.mjs
@@ -16,6 +16,8 @@ const requiredSecrets = [
   'GOOGLE_CLIENT_SECRET',
   'SESSION_SECRET',
   'TOKEN_ENCRYPTION_KEY',
+  'STRIPE_SECRET_KEY',
+  'STRIPE_WEBHOOK_SECRET',
 ];
 
 function fail(message) {
@@ -156,11 +158,15 @@ function listExistingSecrets() {
 async function promptForConfiguration() {
   const rl = createInterface({ input, output });
   try {
-    console.log('\nGoogle + app configuration\n');
+    console.log('\nGoogle + app + Stripe configuration\n');
     const appUrl = (await rl.question('Public URL, for example https://book.example.com: ')).trim().replace(/\/+$/, '');
     const adminEmail = (await rl.question('Google account allowed to administer this calendar: ')).trim();
     const clientId = (await rl.question('Google OAuth Client ID: ')).trim();
     const clientSecret = (await rl.question('Google OAuth Client Secret: ')).trim();
+    console.log('\nStripe configuration');
+    console.log('Use Stripe test-mode values while testing.');
+    const stripeSecretKey = (await rl.question('Stripe Secret Key (sk_test_... or sk_live_...): ')).trim();
+    const stripeWebhookSecret = (await rl.question('Stripe Webhook Signing Secret (whsec_...): ')).trim();
 
     if (!/^https?:\/\//.test(appUrl)) {
       throw new Error('Public URL must begin with https:// (or http:// for local development).');
@@ -168,10 +174,16 @@ async function promptForConfiguration() {
     if (!adminEmail || !clientId || !clientSecret) {
       throw new Error('Admin email and Google OAuth credentials are required.');
     }
+    if (!/^sk_(test|live)_/.test(stripeSecretKey)) {
+      throw new Error('Stripe Secret Key must begin with sk_test_ or sk_live_.');
+    }
+    if (!/^whsec_/.test(stripeWebhookSecret)) {
+      throw new Error('Stripe Webhook Signing Secret must begin with whsec_.');
+    }
 
     console.log(`\nGoogle Authorized redirect URI (required):\n  ${appUrl}/auth/google/callback`);
     console.log(`Google Authorized JavaScript origin (optional here):\n  ${appUrl}\n`);
-    return { appUrl, adminEmail, clientId, clientSecret };
+    return { appUrl, adminEmail, clientId, clientSecret, stripeSecretKey, stripeWebhookSecret };
   } finally {
     rl.close();
   }
@@ -183,6 +195,8 @@ function uploadSecrets(answers, existingSecrets) {
     ADMIN_EMAIL: answers.adminEmail,
     GOOGLE_CLIENT_ID: answers.clientId,
     GOOGLE_CLIENT_SECRET: answers.clientSecret,
+    STRIPE_SECRET_KEY: answers.stripeSecretKey,
+    STRIPE_WEBHOOK_SECRET: answers.stripeWebhookSecret,
   };
 
   if (!existingSecrets.has('SESSION_SECRET')) {
@@ -228,7 +242,7 @@ function assertSecrets(existingSecrets) {
 ensureProjectRoot();
 
 console.log('\nSlot setup\n==========');
-console.log('Cloudflare + Google Calendar. No GitHub Actions, no Pages project, no API token.\n');
+console.log('Cloudflare + Google Calendar + Stripe. No GitHub Actions, no Pages project, no API token.\n');
 
 if ((wrangler(['whoami'], { allowFailure: true, capture: true }).status ?? 1) !== 0) {
   console.log('Opening Cloudflare login...');
diff --git a/src/App.tsx b/src/App.tsx
index 7c92350..b353d9e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -8,6 +8,7 @@ import {
   ChevronRight,
   Clock3,
   Copy,
+  CreditCard,
   ExternalLink,
   Inbox,
   Loader2,
@@ -33,17 +34,27 @@ type PublicConfig = {
   allowMeetingModes: MeetingMode[];
   availableWeekdays: number[];
   connected: boolean;
+  payment?: PaymentOption;
+};
+
+type PaymentOption = {
+  enabled: true;
+  amountMinor: number;
+  currency: string;
+  label: string;
 };
 
 type Slot = { start: string; time: string; durations: number[] };
 
 type Confirmation = {
+  id: string;
   start: string;
   end: string;
   duration: number;
   timezone: string;
   meetingMode: MeetingMode;
   meetUrl?: string | null;
+  payment?: PaymentOption;
 };
 
 type AppSettings = {
@@ -61,6 +72,10 @@ type AppSettings = {
   defaultMeetingMode: MeetingMode;
   allowMeetingModes: MeetingMode[];
   inPersonLocation: string;
+  paymentEnabled: boolean;
+  paymentAmountMinor: number;
+  paymentCurrency: string;
+  paymentLabel: string;
   availability: Record<string, Array<{ start: string; end: string }>>;
 };
 
@@ -78,6 +93,12 @@ type BookingRow = {
   calendar_sync_status?: string;
   calendar_sync_error?: string;
   calendar_sync_updated_at?: string;
+  payment_status?: string;
+  payment_amount?: number;
+  payment_currency?: string;
+  stripe_checkout_session_id?: string;
+  stripe_payment_intent_id?: string;
+  paid_at?: string;
   cancelled_at?: string;
   created_at?: string;
 };
@@ -230,6 +251,19 @@ function formatClock(iso: string, timezone: string): string {
   }).format(new Date(iso));
 }
 
+function formatMoneyMinor(amountMinor: number, currency: string): string {
+  try {
+    const formatter = new Intl.NumberFormat('en-US', {
+      style: 'currency',
+      currency: currency.toUpperCase(),
+    });
+    const digits = formatter.resolvedOptions().maximumFractionDigits ?? 2;
+    return formatter.format(amountMinor / (10 ** digits));
+  } catch {
+    return `${amountMinor} ${currency.toUpperCase()}`;
+  }
+}
+
 function monthCells(month: Date): Array<{ date: Date; current: boolean }> {
   const first = new Date(month.getFullYear(), month.getMonth(), 1, 12);
   const mondayIndex = (first.getDay() + 6) % 7;
@@ -279,6 +313,7 @@ function PublicBooking() {
   const [fieldErrors, setFieldErrors] = useState<{ name?: string; email?: string }>({});
   const [availabilityNotice, setAvailabilityNotice] = useState('');
   const [confirmation, setConfirmation] = useState<Confirmation | null>(null);
+  const paymentReturn = new URLSearchParams(window.location.search).get('payment');
 
   useEffect(() => {
     api<PublicConfig>('/api/config')
@@ -567,6 +602,15 @@ function PublicBooking() {
           <label>Anything I should know? <span className="optional">Optional</span>
             <textarea name="message" rows={3} placeholder="Context, topic, links..." />
           </label>
+          {activeConfig.payment && (
+            <div className="payment-note">
+              <CreditCard size={17} />
+              <div>
+                <strong>Optional online payment</strong>
+                <span>After booking, you can pay {formatMoneyMinor(activeConfig.payment.amountMinor, activeConfig.payment.currency)} securely with Stripe. The reservation does not depend on payment.</span>
+              </div>
+            </div>
+          )}
           <label className="honeypot" aria-hidden="true">Website<input name="website" tabIndex={-1} autoComplete="off" /></label>
 
           {error && <div className="error-banner" role="alert">{error}</div>}
@@ -676,13 +720,56 @@ function PublicBooking() {
       <footer>
         <a className="powered-by" href="https://www.nobgit.com/user/imalexnord/slot" target="_blank" rel="noreferrer">Powered by Slot</a>
       </footer>
+      {paymentReturn && (
+        <div className={`payment-return-banner ${paymentReturn === 'success' ? 'success' : ''}`} role="status">
+          {paymentReturn === 'success'
+            ? 'Stripe checkout completed. Payment status is being recorded, and you can close this tab.'
+            : 'Payment was skipped or cancelled. Your booking is still confirmed.'}
+        </div>
+      )}
     </main>
   );
 }
 
 function ConfirmationView({ title, confirmation, onBookAnother }: { title: string; confirmation: Confirmation; onBookAnother: () => void }) {
   const [copied, copy] = useCopy();
+  const [paying, setPaying] = useState(false);
+  const [paymentError, setPaymentError] = useState('');
+  const [paid, setPaid] = useState(false);
+  const [paymentProcessing, setPaymentProcessing] = useState(false);
   const tz = confirmation.timezone;
+
+  async function openStripeCheckout() {
+    const popup = window.open('about:blank', '_blank');
+    if (popup) popup.opener = null;
+    setPaying(true);
+    setPaymentError('');
+    try {
+      const data = await api<{ url?: string; paid?: boolean; processing?: boolean }>('/api/payments/checkout', {
+        method: 'POST',
+        body: JSON.stringify({ bookingId: confirmation.id }),
+      });
+      if (data.paid) {
+        setPaid(true);
+        popup?.close();
+        return;
+      }
+      if (data.processing) {
+        setPaymentProcessing(true);
+        popup?.close();
+        return;
+      }
+      if (!data.url) throw new Error('Stripe did not return a checkout link.');
+      if (popup) popup.location.href = data.url;
+      else window.location.assign(data.url);
+    } catch (err) {
+      popup?.close();
+      setPaymentError(err instanceof Error ? err.message : 'Could not open Stripe checkout');
+    } finally {
+      setPaying(false);
+    }
+  }
+
   return (
     <div className="panel-view confirmation-panel panel-view-scroll" key="confirmed">
       <span className="success-icon"><Check /></span>
@@ -699,6 +786,29 @@ function ConfirmationView({ title, confirmation, onBookAnother }: { title: strin
 
       <p className="muted">A calendar invitation has been sent to your email.</p>
 
+      {confirmation.payment && (
+        <div className="confirmation-payment">
+          <div>
+            <strong>{confirmation.payment.label}</strong>
+            <span>{paid
+              ? 'Payment received.'
+              : paymentProcessing
+                ? 'Stripe is processing the payment.'
+                : `Optional · ${formatMoneyMinor(confirmation.payment.amountMinor, confirmation.payment.currency)}`}</span>
+          </div>
+          {paid ? (
+            <span className="status-pill paid"><Check size={13} /> Paid</span>
+          ) : paymentProcessing ? (
+            <span className="status-pill upcoming"><Loader2 className="spin" size={13} /> Processing</span>
+          ) : (
+            <button className="secondary-button" type="button" onClick={openStripeCheckout} disabled={paying}>
+              {paying ? <><Loader2 className="spin" size={16} /> Opening Stripe...</> : <><CreditCard size={16} /> Pay with Stripe</>}
+            </button>
+          )}
+        </div>
+      )}
+      {paymentError && <div className="error-banner" role="alert">{paymentError}</div>}
+
       <div className="confirm-actions">
         <a className="primary-button" href={googleCalendarUrl(title, confirmation)} target="_blank" rel="noreferrer">
           <CalendarPlus size={16} /> Add to calendar
@@ -749,7 +859,7 @@ function BookingRowItem({
       </div>
       <div className="booking-meta">
         <span className="booking-method"><Icon size={13} /> {modeMeta[booking.meeting_mode].label}</span>
-        <span>{booking.duration_minutes} min</span>
+        <span>{booking.duration_minutes} min{booking.payment_status === 'paid' ? ' · Paid' : ''}</span>
       </div>
       <div className="booking-actions">
         <span className={`status-pill ${isCancelled ? 'cancelled' : isPast ? 'past' : 'upcoming'}`}>
@@ -799,6 +909,7 @@ function Admin() {
   const [saving, setSaving] = useState(false);
   const [notice, setNotice] = useState('');
   const [error, setError] = useState('');
+  const [stripeConfigured, setStripeConfigured] = useState(false);
 
   useEffect(() => {
     document.title = 'Bookings';
@@ -822,11 +933,12 @@ function Admin() {
   useEffect(() => {
     if (!session?.authenticated) return;
     Promise.all([
-      api<{ settings: AppSettings }>('/api/admin/settings'),
+      api<{ settings: AppSettings; stripeConfigured: boolean }>('/api/admin/settings'),
       api<{ bookings: BookingRow[] }>('/api/admin/bookings'),
       api<{ recurring: RecurringUnavailableRule[]; blocks: AvailabilityBlock[] }>('/api/admin/availability-rules'),
     ]).then(([settingsData, bookingData, availabilityData]) => {
       setSettings(settingsData.settings);
+      setStripeConfigured(settingsData.stripeConfigured);
       setBookings(bookingData.bookings);
       setRecurringUnavailable(availabilityData.recurring);
       setAvailabilityBlocks(availabilityData.blocks);
@@ -1151,6 +1263,30 @@ function Admin() {
                   </div>
                 </section>
 
+                <section className="settings-card">
+                  <div className="card-heading"><h2>Stripe payments</h2><p>Offer payment after the booking is already confirmed.</p></div>
+                  <div className={`connection-badge ${stripeConfigured ? 'connected' : ''}`}>
+                    <span className="status-dot" />
+                    {stripeConfigured ? 'Stripe secrets configured' : 'Stripe secrets missing'}
+                  </div>
+                  <div className="checkbox-chips">
+                    <label className={activeSettings.paymentEnabled ? 'active' : ''}>
+                      <input
+                        type="checkbox"
+                        checked={activeSettings.paymentEnabled}
+                        onChange={(e) => setSettings({ ...activeSettings, paymentEnabled: e.target.checked })}
+                      />
+                      Offer optional payment
+                    </label>
+                  </div>
+                  <div className="two-col-fields">
+                    <label>Currency<input maxLength={3} value={activeSettings.paymentCurrency} onChange={(e) => setSettings({ ...activeSettings, paymentCurrency: e.target.value.toLowerCase() })} /><small>ISO</small></label>
+                    <label>Amount<input type="number" min="0" step="1" value={activeSettings.paymentAmountMinor} onChange={(e) => setSettings({ ...activeSettings, paymentAmountMinor: Number(e.target.value) })} /><small>minor unit</small></label>
+                  </div>
+                  <label>Checkout label<input value={activeSettings.paymentLabel} onChange={(e) => setSettings({ ...activeSettings, paymentLabel: e.target.value })} placeholder="Booking payment" /></label>
+                  <p className="theme-footnote">Example: 5000 with USD is $50.00. Add STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET as Worker secrets before enabling this.</p>
+                </section>
+
                 <section className="settings-card wide-card">
                   <div className="card-heading"><h2>Google Calendar</h2><p>Busy time is read from Google. New bookings are created on your chosen calendar.</p></div>
                   <div className="google-status-row">
@@ -1259,6 +1395,11 @@ function Admin() {
                     <div><dt>Meeting</dt><dd>{modeMeta[selectedBooking.meeting_mode].label}</dd></div>
                     <div><dt>Calendar</dt><dd>{selectedBooking.calendar_provider ?? 'google'}</dd></div>
                     <div><dt>Sync</dt><dd>{selectedBooking.calendar_sync_status ?? 'synced'}</dd></div>
+                    <div><dt>Payment</dt><dd>{selectedBooking.payment_status ?? 'not_requested'}</dd></div>
+                    {selectedBooking.payment_amount != null && selectedBooking.payment_currency && (
+                      <div><dt>Amount</dt><dd>{formatMoneyMinor(selectedBooking.payment_amount, selectedBooking.payment_currency)}</dd></div>
+                    )}
+                    {selectedBooking.paid_at && <div><dt>Paid</dt><dd>{formatInstant(`${selectedBooking.paid_at.replace(' ', 'T')}Z`, activeSettings.timezone)}</dd></div>}
                     {selectedBooking.created_at && <div><dt>Created</dt><dd>{formatInstant(`${selectedBooking.created_at.replace(' ', 'T')}Z`, activeSettings.timezone)}</dd></div>}
                     {selectedBooking.cancelled_at && <div><dt>Cancelled</dt><dd>{formatInstant(`${selectedBooking.cancelled_at.replace(' ', 'T')}Z`, activeSettings.timezone)}</dd></div>}
                   </dl>
@@ -1296,6 +1437,9 @@ function Admin() {
               <span>{cancelTarget.duration_minutes} min</span>
             </div>
             <p className="muted">This will also cancel the Google Calendar event and send normal Calendar cancellation notifications.</p>
+            {cancelTarget.payment_status === 'paid' && (
+              <div className="error-banner" role="alert">This booking is paid. Cancelling the calendar event will not issue a Stripe refund. Refund it separately in Stripe if needed.</div>
+            )}
             <div className="dialog-actions">
               <button className="secondary-button" type="button" onClick={() => setCancelTarget(null)} disabled={cancelling}>Keep meeting</button>
               <button className="danger-button" type="button" onClick={confirmCancelBooking} disabled={cancelling}>
diff --git a/src/styles.css b/src/styles.css
index 6f6ee73..606a023 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -677,6 +677,20 @@ input[aria-invalid="true"]:focus { box-shadow: 0 0 0 3px color-mix(in srgb, var(
 .mode-option small { color: var(--faint); font-size: 9px; line-height: 1.35; font-weight: 500; }
 .mode-option.active { border-color: var(--text); background: var(--surface-soft); }
 .mode-option svg { flex-shrink: 0; }
+.payment-note {
+  display: flex;
+  align-items: flex-start;
+  gap: 10px;
+  padding: 10px 11px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  background: var(--surface-soft);
+  color: var(--muted);
+}
+.payment-note svg { flex-shrink: 0; color: var(--text); }
+.payment-note div { display: grid; gap: 3px; }
+.payment-note strong { color: var(--text); font-size: 11px; }
+.payment-note span { font-size: 10px; line-height: 1.45; }
 
 .primary-button {
   border: 0;
@@ -746,6 +760,26 @@ input[aria-invalid="true"]:focus { box-shadow: 0 0 0 3px color-mix(in srgb, var(
   box-shadow: var(--shadow);
   background: color-mix(in srgb, var(--danger) 12%, var(--surface));
 }
+.payment-return-banner {
+  position: fixed;
+  left: 50%;
+  bottom: 56px;
+  transform: translateX(-50%);
+  width: min(680px, calc(100vw - 32px));
+  z-index: 31;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-xs);
+  padding: 11px 13px;
+  background: var(--surface);
+  color: var(--muted);
+  box-shadow: var(--shadow);
+  font-size: 11px;
+  line-height: 1.45;
+}
+.payment-return-banner.success {
+  color: var(--success);
+  border-color: color-mix(in srgb, var(--success) 28%, var(--border));
+}
 .booking-page footer {
   position: fixed;
   left: 50%;
@@ -834,6 +868,20 @@ input[aria-invalid="true"]:focus { box-shadow: 0 0 0 3px color-mix(in srgb, var(
 }
 .confirm-row dt { color: var(--muted); font-weight: 600; }
 .confirm-row dd { margin: 0; color: var(--text); font-weight: 660; text-align: right; }
+.confirmation-payment {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 12px 13px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  background: var(--surface-soft);
+}
+.confirmation-payment > div { display: grid; gap: 3px; min-width: 0; }
+.confirmation-payment strong { color: var(--text); font-size: 12px; }
+.confirmation-payment span { color: var(--muted); font-size: 10px; }
+.confirmation-payment .secondary-button { min-height: 36px; padding: 9px 12px; white-space: nowrap; }
 .confirm-actions { display: grid; gap: 8px; margin-top: 4px; }
 .copy-button {
   border: 1px solid var(--border);
@@ -1183,6 +1231,7 @@ select { width: 100%; color: var(--text); background: var(--surface-soft); borde
 .status-pill.upcoming { color: var(--success); border-color: color-mix(in srgb, var(--success) 40%, var(--border)); background: color-mix(in srgb, var(--success) 8%, transparent); }
 .status-pill.past { color: var(--faint); }
 .status-pill.cancelled { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); background: color-mix(in srgb, var(--danger) 8%, transparent); }
+.status-pill.paid { color: var(--success); border-color: color-mix(in srgb, var(--success) 40%, var(--border)); background: color-mix(in srgb, var(--success) 8%, transparent); }
 .booking-actions { display: inline-flex; align-items: center; gap: 6px; justify-self: end; }
 .icon-button {
   width: var(--control-h-sm);
diff --git a/worker/index.ts b/worker/index.ts
index 87128a0..754fe78 100644
--- a/worker/index.ts
+++ b/worker/index.ts
@@ -16,6 +16,14 @@ import {
   queryBusy,
 } from './google';
 import { DEFAULT_SETTINGS, getSettings, saveSettings } from './settings';
+import {
+  createStripeCheckoutSession,
+  retrieveStripeCheckoutSession,
+  stripeConfigured,
+  stripePaymentIntentId,
+  StripeError,
+  verifyStripeWebhook,
+} from './stripe';
 import {
   addDaysToDateKey,
   addMinutes,
@@ -145,6 +153,16 @@ function validateAppSettings(input: unknown): AppSettings {
     defaultMeetingMode,
     allowMeetingModes: normalizedModes,
     inPersonLocation: String(raw.inPersonLocation ?? '').trim().slice(0, 240),
+    paymentEnabled: Boolean(raw.paymentEnabled),
+    paymentAmountMinor: Math.min(
+      999_999_999,
+      Math.max(0, Math.round(Number(raw.paymentAmountMinor ?? DEFAULT_SETTINGS.paymentAmountMinor))),
+    ),
+    paymentCurrency: /^[a-zA-Z]{3}$/.test(String(raw.paymentCurrency ?? ''))
+      ? String(raw.paymentCurrency).toLowerCase()
+      : DEFAULT_SETTINGS.paymentCurrency,
+    paymentLabel: String(raw.paymentLabel ?? DEFAULT_SETTINGS.paymentLabel).trim().slice(0, 120)
+      || DEFAULT_SETTINGS.paymentLabel,
     availability,
   };
 }
@@ -354,6 +372,9 @@ async function createBooking(env: Env, httpRequest: Request, request: BookingReq
   const id = crypto.randomUUID();
   const lockExpiry = Date.now() + 3 * 60_000;
   const keys = slotKeys(lockStart, lockEnd, settings.slotIncrementMinutes);
+  const paymentAvailable = settings.paymentEnabled
+    && settings.paymentAmountMinor > 0
+    && stripeConfigured(env);
 
   await env.DB.prepare('DELETE FROM slot_locks WHERE expires_at < ?1').bind(Date.now()).run();
 
@@ -361,8 +382,9 @@ async function createBooking(env: Env, httpRequest: Request, request: BookingReq
     env.DB.prepare(`
       INSERT INTO bookings (
         id, name, email, phone, message, start_time, end_time,
-        duration_minutes, timezone, meeting_mode, status
-      ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'pending')
+        duration_minutes, timezone, meeting_mode, status,
+        payment_status, payment_amount, payment_currency
+      ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'pending', ?11, ?12, ?13)
     `).bind(
       id,
       name,
@@ -374,6 +396,9 @@ async function createBooking(env: Env, httpRequest: Request, request: BookingReq
       duration,
       settings.timezone,
       request.meetingMode,
+      paymentAvailable ? 'available' : 'not_requested',
+      paymentAvailable ? settings.paymentAmountMinor : null,
+      paymentAvailable ? settings.paymentCurrency : null,
     ),
     ...keys.map((key) =>
       env.DB.prepare('INSERT INTO slot_locks (slot_key, booking_id, expires_at) VALUES (?1, ?2, ?3)')
@@ -431,6 +456,12 @@ async function createBooking(env: Env, httpRequest: Request, request: BookingReq
         duration,
         timezone: settings.timezone,
         meetUrl: created.meetUrl,
+        payment: paymentAvailable ? {
+          enabled: true,
+          amountMinor: settings.paymentAmountMinor,
+          currency: settings.paymentCurrency,
+          label: settings.paymentLabel,
+        } : undefined,
       },
     }, 201);
   } catch (error) {
@@ -444,6 +475,158 @@ async function createBooking(env: Env, httpRequest: Request, request: BookingReq
   }
 }
 
+async function createPaymentCheckout(env: Env, bookingId: string): Promise<Response> {
+  if (!/^[0-9a-f-]{36}$/i.test(bookingId)) return json({ error: 'Invalid booking id.' }, 400);
+  if (!stripeConfigured(env)) return json({ error: 'Stripe is not configured for this deployment.' }, 503);
+
+  const booking = await env.DB.prepare(`
+    SELECT id, email, duration_minutes, status, payment_status,
+           payment_amount, payment_currency, stripe_checkout_session_id
+    FROM bookings
+    WHERE id = ?1
+  `).bind(bookingId).first<{
+    id: string;
+    email: string;
+    duration_minutes: number;
+    status: string;
+    payment_status: string;
+    payment_amount: number | null;
+    payment_currency: string | null;
+    stripe_checkout_session_id: string | null;
+  }>();
+
+  if (!booking) return json({ error: 'Booking not found.' }, 404);
+  if (booking.status !== 'confirmed') return json({ error: 'Only confirmed bookings can be paid.' }, 409);
+  if (booking.payment_status === 'paid') return json({ ok: true, paid: true });
+  if (booking.payment_status === 'not_requested') {
+    return json({ error: 'Online payment was not offered for this booking.' }, 409);
+  }
+
+  const settings = await getSettings(env);
+  if (!settings.paymentEnabled) return json({ error: 'Online payment is not enabled.' }, 409);
+
+  const amountMinor = booking.payment_amount;
+  const currency = booking.payment_currency;
+  if (typeof amountMinor !== 'number' || !Number.isInteger(amountMinor) || amountMinor <= 0 || !currency || !/^[a-z]{3}$/.test(currency)) {
+    return json({ error: 'Payment amount is not configured.' }, 409);
+  }
+
+  try {
+    if (booking.stripe_checkout_session_id && booking.payment_status !== 'failed') {
+      const existing = await retrieveStripeCheckoutSession(env, booking.stripe_checkout_session_id);
+      if (existing.payment_status === 'paid') {
+        await env.DB.prepare(`
+          UPDATE bookings
+          SET payment_status = 'paid',
+              stripe_payment_intent_id = ?1,
+              paid_at = COALESCE(paid_at, datetime('now'))
+          WHERE id = ?2
+        `).bind(stripePaymentIntentId(existing), booking.id).run();
+        return json({ ok: true, paid: true });
+      }
+      if (existing.status === 'open' && existing.url) {
+        return json({ ok: true, url: existing.url });
+      }
+      if (existing.status === 'complete') {
+        return json({ ok: true, processing: true });
+      }
+    }
+
+    const session = await createStripeCheckoutSession(env, {
+      id: booking.id,
+      email: booking.email,
+      durationMinutes: booking.duration_minutes,
+      amountMinor,
+      currency,
+      label: settings.paymentLabel,
+    });
+    if (!session.id || !session.url) throw new StripeError('Stripe did not return a Checkout URL.');
+
+    await env.DB.prepare(`
+      UPDATE bookings
+      SET payment_status = 'checkout_open',
+          payment_amount = ?1,
+          payment_currency = ?2,
+          stripe_checkout_session_id = ?3
+      WHERE id = ?4
+    `).bind(amountMinor, currency, session.id, booking.id).run();
+
+    return json({ ok: true, url: session.url });
+  } catch (error) {
+    if (error instanceof StripeError) return json({ error: error.message }, error.status);
+    throw error;
+  }
+}
+
+async function handleStripeWebhook(request: Request, env: Env): Promise<Response> {
+  const rawBody = await request.text();
+  try {
+    const event = await verifyStripeWebhook(env, rawBody, request.headers.get('Stripe-Signature'));
+    const session = event.data?.object;
+    if (!session || session.object !== 'checkout.session') return json({ received: true });
+
+    const bookingId = session.metadata?.booking_id || session.client_reference_id;
+    if (!bookingId || !/^[0-9a-f-]{36}$/i.test(bookingId)) return json({ received: true });
+
+    if (event.type === 'checkout.session.completed' || event.type === 'checkout.session.async_payment_succeeded') {
+      const paid = session.payment_status === 'paid' || event.type === 'checkout.session.async_payment_succeeded';
+      if (paid) {
+        await env.DB.prepare(`
+          UPDATE bookings
+          SET payment_status = 'paid',
+              payment_amount = COALESCE(?1, payment_amount),
+              payment_currency = COALESCE(?2, payment_currency),
+              stripe_checkout_session_id = ?3,
+              stripe_payment_intent_id = ?4,
+              paid_at = COALESCE(paid_at, datetime('now'))
+          WHERE id = ?5
+        `).bind(
+          session.amount_total ?? null,
+          session.currency ?? null,
+          session.id,
+          stripePaymentIntentId(session),
+          bookingId,
+        ).run();
+      } else {
+        await env.DB.prepare(`
+          UPDATE bookings
+          SET payment_status = CASE WHEN payment_status = 'paid' THEN 'paid' ELSE 'processing' END,
+              payment_amount = COALESCE(?1, payment_amount),
+              payment_currency = COALESCE(?2, payment_currency),
+              stripe_payment_intent_id = COALESCE(?3, stripe_payment_intent_id)
+          WHERE id = ?4
+            AND (stripe_checkout_session_id IS NULL OR stripe_checkout_session_id = ?5)
+        `).bind(
+          session.amount_total ?? null,
+          session.currency ?? null,
+          stripePaymentIntentId(session),
+          bookingId,
+          session.id,
+        ).run();
+      }
+    } else if (event.type === 'checkout.session.async_payment_failed') {
+      await env.DB.prepare(`
+        UPDATE bookings
+        SET payment_status = CASE WHEN payment_status = 'paid' THEN 'paid' ELSE 'failed' END
+        WHERE id = ?1
+          AND stripe_checkout_session_id = ?2
+      `).bind(bookingId, session.id).run();
+    } else if (event.type === 'checkout.session.expired') {
+      await env.DB.prepare(`
+        UPDATE bookings
+        SET payment_status = CASE WHEN payment_status = 'paid' THEN payment_status ELSE 'available' END
+        WHERE id = ?1
+          AND stripe_checkout_session_id = ?2
+      `).bind(bookingId, session.id).run();
+    }
+
+    return json({ received: true });
+  } catch (error) {
+    if (error instanceof StripeError) return json({ error: error.message }, error.status);
+    throw error;
+  }
+}
+
 async function cancelBooking(env: Env, bookingId: string): Promise<Response> {
   if (!/^[0-9a-f-]{36}$/i.test(bookingId)) return json({ error: 'Invalid booking id.' }, 400);
   const booking = await env.DB.prepare(`
@@ -583,6 +766,9 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
   if (request.method === 'GET' && url.pathname === '/api/config') {
     const settings = await getSettings(env);
     const connected = await hasGoogleConnection(env);
+    const paymentEnabled = settings.paymentEnabled
+      && settings.paymentAmountMinor > 0
+      && stripeConfigured(env);
     return json({
       title: settings.title,
       subtitle: settings.subtitle,
@@ -595,6 +781,12 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
         .filter(([, intervals]) => intervals.length > 0)
         .map(([day]) => Number(day)),
       connected,
+      payment: paymentEnabled ? {
+        enabled: true,
+        amountMinor: settings.paymentAmountMinor,
+        currency: settings.paymentCurrency,
+        label: settings.paymentLabel,
+      } : undefined,
     });
   }
 
@@ -628,6 +820,15 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
     return createBooking(env, request, body);
   }
 
+  if (request.method === 'POST' && url.pathname === '/api/payments/checkout') {
+    const body = (await request.json()) as { bookingId?: string };
+    return createPaymentCheckout(env, String(body.bookingId ?? ''));
+  }
+
+  if (request.method === 'POST' && url.pathname === '/api/stripe/webhook') {
+    return handleStripeWebhook(request, env);
+  }
+
   if (url.pathname.startsWith('/api/admin/')) {
     const admin = await requireAdmin(request, env);
     if (!admin) return json({ error: 'Unauthorized' }, 401);
@@ -638,7 +839,7 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
     }
 
     if (request.method === 'GET' && url.pathname === '/api/admin/settings') {
-      return json({ settings: await getSettings(env) });
+      return json({ settings: await getSettings(env), stripeConfigured: stripeConfigured(env) });
     }
 
     if (request.method === 'PUT' && url.pathname === '/api/admin/settings') {
@@ -652,7 +853,9 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
         SELECT id, name, email, start_time, end_time, duration_minutes,
                meeting_mode, status, meet_url, created_at,
                cancelled_at, calendar_provider, calendar_sync_status,
-               calendar_sync_error, calendar_sync_updated_at
+               calendar_sync_error, calendar_sync_updated_at,
+               payment_status, payment_amount, payment_currency,
+               stripe_checkout_session_id, stripe_payment_intent_id, paid_at
         FROM bookings
         WHERE status != 'pending'
         ORDER BY start_time DESC
diff --git a/worker/settings.ts b/worker/settings.ts
index b88eb3a..f9cc20c 100644
--- a/worker/settings.ts
+++ b/worker/settings.ts
@@ -15,6 +15,10 @@ export const DEFAULT_SETTINGS: AppSettings = {
   defaultMeetingMode: 'google_meet',
   allowMeetingModes: ['google_meet'],
   inPersonLocation: '',
+  paymentEnabled: false,
+  paymentAmountMinor: 0,
+  paymentCurrency: 'usd',
+  paymentLabel: 'Booking payment',
   availability: {
     '0': [],
     '1': [{ start: '09:00', end: '17:00' }],
diff --git a/worker/stripe.ts b/worker/stripe.ts
new file mode 100644
index 0000000..9474a6e
--- /dev/null
+++ b/worker/stripe.ts
@@ -0,0 +1,165 @@
+import type { Env } from './types';
+
+export class StripeError extends Error {
+  constructor(message: string, public readonly status = 502) {
+    super(message);
+  }
+}
+
+type StripeCheckoutSession = {
+  id: string;
+  object: 'checkout.session';
+  url?: string | null;
+  status?: 'open' | 'complete' | 'expired' | null;
+  payment_status?: 'paid' | 'unpaid' | 'no_payment_required';
+  amount_total?: number | null;
+  currency?: string | null;
+  client_reference_id?: string | null;
+  payment_intent?: string | { id?: string } | null;
+  metadata?: Record<string, string> | null;
+};
+
+type StripeEvent = {
+  id: string;
+  type: string;
+  data?: { object?: StripeCheckoutSession };
+};
+
+function stripeSecret(env: Env): string {
+  const key = env.STRIPE_SECRET_KEY?.trim();
+  if (!key) throw new StripeError('Stripe is not configured for this deployment.', 503);
+  return key;
+}
+
+async function stripeRequest<T>(env: Env, path: string, init: RequestInit = {}): Promise<T> {
+  let response: Response;
+  try {
+    response = await fetch(`https://api.stripe.com${path}`, {
+      ...init,
+      headers: {
+        Authorization: `Bearer ${stripeSecret(env)}`,
+        ...(init.body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}),
+        ...(init.headers ?? {}),
+      },
+    });
+  } catch {
+    throw new StripeError('Could not reach Stripe. Try again in a moment.', 502);
+  }
+
+  const data = (await response.json().catch(() => ({}))) as T & {
+    error?: { message?: string };
+  };
+  if (!response.ok) {
+    throw new StripeError(data.error?.message || `Stripe request failed (${response.status}).`, 502);
+  }
+  return data;
+}
+
+export function stripeConfigured(env: Env): boolean {
+  return Boolean(env.STRIPE_SECRET_KEY?.trim() && env.STRIPE_WEBHOOK_SECRET?.trim());
+}
+
+export async function createStripeCheckoutSession(
+  env: Env,
+  booking: {
+    id: string;
+    email: string;
+    durationMinutes: number;
+    amountMinor: number;
+    currency: string;
+    label: string;
+  },
+): Promise<StripeCheckoutSession> {
+  const params = new URLSearchParams();
+  params.set('mode', 'payment');
+  params.set('success_url', `${env.APP_URL.replace(/\/+$/, '')}/?payment=success`);
+  params.set('cancel_url', `${env.APP_URL.replace(/\/+$/, '')}/?payment=cancelled`);
+  params.set('client_reference_id', booking.id);
+  params.set('customer_email', booking.email);
+  params.set('metadata[booking_id]', booking.id);
+  params.set('payment_intent_data[metadata][booking_id]', booking.id);
+  params.set('line_items[0][price_data][currency]', booking.currency.toLowerCase());
+  params.set('line_items[0][price_data][unit_amount]', String(booking.amountMinor));
+  params.set('line_items[0][price_data][product_data][name]', booking.label);
+  params.set(
+    'line_items[0][price_data][product_data][description]',
+    `${booking.durationMinutes}-minute booking`,
+  );
+  params.set('line_items[0][quantity]', '1');
+
+  return stripeRequest<StripeCheckoutSession>(env, '/v1/checkout/sessions', {
+    method: 'POST',
+    body: params.toString(),
+  });
+}
+
+export async function retrieveStripeCheckoutSession(env: Env, sessionId: string): Promise<StripeCheckoutSession> {
+  if (!/^cs_(test_|live_)?[A-Za-z0-9_]+$/.test(sessionId)) {
+    throw new StripeError('Invalid Stripe Checkout session id.', 400);
+  }
+  return stripeRequest<StripeCheckoutSession>(env, `/v1/checkout/sessions/${encodeURIComponent(sessionId)}`);
+}
+
+function constantTimeEqual(left: string, right: string): boolean {
+  if (left.length !== right.length) return false;
+  let result = 0;
+  for (let index = 0; index < left.length; index += 1) {
+    result |= left.charCodeAt(index) ^ right.charCodeAt(index);
+  }
+  return result === 0;
+}
+
+function bytesToHex(bytes: Uint8Array): string {
+  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
+}
+
+async function hmacSha256Hex(secret: string, message: string): Promise<string> {
+  const encoder = new TextEncoder();
+  const key = await crypto.subtle.importKey(
+    'raw',
+    encoder.encode(secret),
+    { name: 'HMAC', hash: 'SHA-256' },
+    false,
+    ['sign'],
+  );
+  const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(message));
+  return bytesToHex(new Uint8Array(signature));
+}
+
+export async function verifyStripeWebhook(
+  env: Env,
+  rawBody: string,
+  signatureHeader: string | null,
+  toleranceSeconds = 300,
+): Promise<StripeEvent> {
+  const secret = env.STRIPE_WEBHOOK_SECRET?.trim();
+  if (!secret) throw new StripeError('Stripe webhook secret is not configured.', 503);
+  if (!signatureHeader) throw new StripeError('Missing Stripe-Signature header.', 400);
+
+  const parts = signatureHeader.split(',').map((part) => part.trim());
+  const timestamp = parts.find((part) => part.startsWith('t='))?.slice(2);
+  const signatures = parts.filter((part) => part.startsWith('v1=')).map((part) => part.slice(3));
+  const timestampNumber = Number(timestamp);
+  if (!timestamp || !Number.isFinite(timestampNumber) || signatures.length === 0) {
+    throw new StripeError('Invalid Stripe webhook signature.', 400);
+  }
+
+  const ageSeconds = Math.abs(Date.now() / 1000 - timestampNumber);
+  if (ageSeconds > toleranceSeconds) throw new StripeError('Expired Stripe webhook signature.', 400);
+
+  const expected = await hmacSha256Hex(secret, `${timestamp}.${rawBody}`);
+  if (!signatures.some((signature) => constantTimeEqual(signature, expected))) {
+    throw new StripeError('Invalid Stripe webhook signature.', 400);
+  }
+
+  try {
+    return JSON.parse(rawBody) as StripeEvent;
+  } catch {
+    throw new StripeError('Invalid Stripe webhook payload.', 400);
+  }
+}
+
+export function stripePaymentIntentId(session: StripeCheckoutSession): string | null {
+  if (typeof session.payment_intent === 'string') return session.payment_intent;
+  return session.payment_intent?.id ?? null;
+}
diff --git a/worker/types.ts b/worker/types.ts
index 140a38b..6d38da5 100644
--- a/worker/types.ts
+++ b/worker/types.ts
@@ -7,6 +7,8 @@ export interface Env {
   GOOGLE_CLIENT_SECRET: string;
   SESSION_SECRET: string;
   TOKEN_ENCRYPTION_KEY: string;
+  STRIPE_SECRET_KEY?: string;
+  STRIPE_WEBHOOK_SECRET?: string;
 }
 
 export interface AppSettings {
@@ -24,6 +26,10 @@ export interface AppSettings {
   defaultMeetingMode: MeetingMode;
   allowMeetingModes: MeetingMode[];
   inPersonLocation: string;
+  paymentEnabled: boolean;
+  paymentAmountMinor: number;
+  paymentCurrency: string;
+  paymentLabel: string;
   availability: WeeklyAvailability;
 }
 
diff --git a/wrangler.jsonc b/wrangler.jsonc
index ea4dd4c..9359b75 100644
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -5,13 +5,17 @@
   "compatibility_date": "2026-07-25",
   "assets": {
     "not_found_handling": "single-page-application",
-    "run_worker_first": ["/api/*", "/auth/*"]
+    "run_worker_first": [
+      "/api/*",
+      "/auth/*"
+    ]
   },
   "d1_databases": [
     {
       "binding": "DB",
       "database_name": "slot-db",
-      "migrations_dir": "migrations"
+      "migrations_dir": "migrations",
+      "database_id": "0b786b3a-fac1-4e26-b1b5-1c994ed4026e"
     }
   ]
 }