← Portfolio

GolfKitA modern booking engine and guest experience for golf resorts

GolfKit is a booking platform built on top of Lightspeed Golf, designed to give resorts a guest-first booking experience — branded per venue, cached for speed, and resilient when the upstream API fails — without requiring them to change the system of record they already use. In active development since January 2026 and deployed to golfkit.io.

1. The Problem

This started with a friend in the golf industry describing the pain point: booking engines are designed around the system of record, not around the guest. He knows the hospitality side; I know product and engineering. The more I looked at the problem, the more I wanted to build something better. Resorts run on platforms like Lightspeed Golf, which manage tee sheets, player types, and products internally, but the booking experience those systems expose is functional rather than welcoming. It is not built for conversion, not built for brand expression, and not built with the guest’s journey in mind.

A great front-end needs something reliable behind it, and the APIs underneath these systems have real constraints. Lightspeed enforces rate limits that make live browsing impractical at scale. There is no caching, no multi-system transaction safety, and no webhook infrastructure. Products and packages (“kits”) exist in the back-end, but there is no admin-friendly way to curate what the guest sees: to rename things, toggle visibility, or present a selection that fits the brand rather than the data model.

The result is a gap between what a resort wants to offer and what the underlying system can deliver:

The goal was to sit on top of the system resorts already use and give them a better guest experience, with the operational tooling and reliability to support it. That is what GolfKit does.

Want the implementation detail?

This page explains the product decisions behind GolfKit and the system design that followed from them. The deeper build documentation lives in the separate technical reference, including the full data model, API surface, caching formulas, adapter interfaces, security patterns, and deployment configuration.

Open the GolfKit technical reference →

2. The Guest Experience

The starting point for everything was a question: what would the best possible booking experience look like for a golf guest? Not for the system. Not for the admin. For the person trying to book a round.

The answer started with friction. Forcing people to create an account before they can book is a conversion problem. It exists because it is convenient for the system, not because it helps the guest. In GolfKit, guests book as guests: name, email, phone, done. No account required. For guests who return regularly, venues can enable customer accounts: guests sign in via a configured identity provider, see their booking history across visits, and can link new bookings to their profile. Accounts are optional and tenant-controlled. A guest who never creates one loses nothing. This does mean a new customer record is created each time for anonymous guests, but customer deduplication is a separate concern that belongs to specialist systems, not the booking engine. The booking engine should work around the customer, not force the customer into its mould.

The booking journey

0SearchDate picker with availability dots, group size selector
1RateGreen fee vs package selection with “Best Value” highlighting
2DetailsLead booker name, email, phone; optional additional players
3ExtrasCurated add-ons by category, occasions, special requests
4ReviewFull summary with edit links, notice acknowledgement, pricing breakdown
5ConfirmStripe payment, booking reference, confirmation email

The calendar shows availability at a glance: green dots for dates with open slots, grey for no availability, red crosses for closed days. When a date is fully booked and the waiting list is enabled, a “Notify me when a slot opens up” prompt appears instead of an empty state, because telling someone “no” when you could be capturing their interest is a missed opportunity.

Tee times update in real time via Server-Sent Events. If another guest books a slot while someone is browsing, the availability changes without a page refresh. Guests can toggle between per-person and total pricing. A small decision, but some people think in per-head cost and others in group cost, and forcing one framing adds friction that does not need to exist.

After the booking

Guests receive a confirmation email with a manage link. They can view their booking, update special requests, or cancel, all without an account. The cancellation flow respects the venue’s refund policy. Access is controlled by email plus booking reference, with rate limiting to prevent abuse.

Venues can also configure pre-tee-time reminder emails at one or more lead times (for example, 48 hours and 2 hours before). The booking reminder service schedules these at the point of booking and fires them automatically, emitting a booking.reminder webhook event that the email service picks up like any other domain event.

Each venue also gets a branded check-in kiosk page. Guests look up their booking by email or reference, see their tee time and group details, and check in with a single tap. The kiosk auto-resets after 8 seconds, ready for the next guest. The first person to check in triggers the reservation-level check-in with Lightspeed; individual check-ins are tracked per person for the venue’s records.

Session resilience

Booking state is encrypted with AES-GCM and persisted in the browser. If a guest navigates away mid-flow, they can resume within a 30-minute window. The encryption key lives in sessionStorage (cleared on tab close) while the ciphertext lives in localStorage, so a page refresh is fine, but closing the browser starts fresh. The point is that the booking flow should tolerate the way people actually use the web, not punish them for it.

Product thinking: Every decision in the guest flow asks the same question: where could this lose someone, and how do we avoid it? The answer is always the same: remove the obstacle, not the guest.

3. Branding & Identity

A booking engine that looks the same for every venue is not a booking engine. It is a form. Building this as a multi-tenant platform meant each venue had to feel like its own property, not like a generic widget with a logo swapped in.

Custom subdomains and domains

Each venue gets a human-readable slug (the-grove) that maps to all guest-facing pages: booking, check-in, and manage. Venues that want their own domain (book.thegrove.com) can configure a custom hostname with automatic TLS certificate provisioning via Render. The booking page, admin portal, check-in kiosk, and manage page all route correctly under both.

Theme system

The theme is not a colour picker. It is a full branding surface:

Visual identity

Logo, hero image, primary and accent colours, card radii, shadow depths, button styles, all applied via CSS custom properties. The hero image is preloaded with a fade-in animation so the first impression is polished.

Typography

Display and body fonts are configurable with full Google Fonts support. A resort using Cormorant for headings and Source Sans for body text creates a very different impression from one using Inter throughout, and the booking engine should reflect that.

Copy and labels

Button text, step names, header subtitles, confirmation messages, and currency symbols are all configurable per venue, with sensible defaults. No guest-facing text needs to use the platform’s wording if the venue has its own voice.

Course configuration

Multi-course venues can define short labels, display names, hole counts, and display order per course. When multiple courses are available, a filter bar appears. Single-course venues see no filter. The UI adapts rather than showing irrelevant controls.

Venues can also pause the entire site and replace it with custom content or a redirect, useful during maintenance, severe weather, or seasonal closures. It is a small feature, but it reflects the broader principle: the system should consider the needs of the golf course, not just the needs of the software.

Onboarding head-start

Getting a new venue from sign-up to a themed, live booking page takes time if the admin starts from a blank slate. The platform can discover a venue’s existing website and use it to generate a candidate theme automatically: colours, fonts, copy tone, and logo placement derived from what the venue already presents to the world. To make this instant rather than on-demand, I pre-ran this process at scale across thousands of golf courses worldwide, building a database of names, locations, and pre-generated themes ahead of any sign-up. When a recognisable venue joins, their initial theme is already waiting for them. The onboarding experience shifts from a blank configuration form to “here is what your booking page already looks like”, a much more compelling first impression for a venue evaluating the product.

4. Product Curation

Lightspeed has products and packages (“kits”), but the back-end is often messy. It contains internal products, seasonal items that are temporarily irrelevant, and names that make sense in the system but not on a guest-facing page. Deleting them from the system of record is not an option. Showing everything as-is is not a guest experience.

I needed a curation layer: something that lets admins control what the guest sees without touching the underlying system. The layer also abstracts away which system is providing products, which means extras could be fulfilled by multiple systems if desired.

Two modes

Passthrough

Products are served directly from Lightspeed via a Redis cache. Zero admin effort: what Lightspeed has is what the guest sees. Useful for venues where the back-end product catalog is already clean.

Customised

Products are synced from Lightspeed into GolfKit’s database, where admins have full control. New products arrive hidden by default, requiring explicit review before they appear to guests. Admins can rename, describe, categorise, feature, reorder, and toggle visibility, all without touching the system of record.

How sync works

In customised mode, a background sync runs every 12 hours (or on demand). It fetches the current product catalog from Lightspeed and diffs it against the local database. New products are inserted as hidden. Existing products have their name and price updated from the source, but admin curation is never overwritten: display names, descriptions, categories, visibility, and featured status are preserved across syncs. If a product disappears from Lightspeed, it is flagged and published items are automatically demoted. The admin sees a warning and acknowledges it.

Admins organise products into categories with display slots: featured (highlighted at the top), extras (the main add-on section), and occasions (birthday, corporate, celebration items). Multi-course venues can also restrict which products appear for which course, handled transparently so the guest simply sees the right products for their booking.

The package layer

Alongside the Lightspeed product layer, the middleware manages curated packages and individual items through its own package route layer. Each package is a named bundle with per-player and fixed pricing components. The package layer uses an adapter pattern to source products from external systems, currently Lightspeed, while keeping the interface open for additional systems. Connecting a different provider (F&B, equipment rental, experiences) requires a new adapter, not a rewrite. The guest sees packages alongside green fees with no awareness of which system is behind them.

Product thinking: The curation layer exists because the system of record and the guest experience have different needs. Lightspeed needs a complete product catalog. The guest needs a curated, well-presented selection. GolfKit lets both be true without forcing the admin to compromise either.

5. Operational Control

Running a golf resort means dealing with weather, maintenance, corporate events, and schedule changes. The booking engine needs to support these realities without requiring the operator to work around the system.

Notices

Admins can attach notices to any date with three severity levels: informational, warning, and alert. A notice can carry a message (“Course will be slower than usual due to maintenance”), and optionally require the guest to acknowledge it before completing their booking. Notices appear prominently in the booking flow. The guest cannot miss them.

Day and time blocking

Full-day closures are straightforward: mark the day as unavailable and the calendar shows it as closed. But resorts rarely need to close an entire day. More often they need to block a specific time range (11:00–13:00 for a shotgun start, for example) while keeping morning and afternoon slots open. That required time-range blocking, not just a binary switch. Guests still see the date as partially available, but blocked slots are not bookable.

When blocking is applied to a date that already has bookings, the admin can bulk-cancel affected bookings in one action: either all bookings on the day or only those in the blocked ranges. Each cancellation follows the normal refund flow.

Waiting list

Rather than just telling someone “no availability”, the waiting list captures their interest. It is deliberately day-based rather than slot-based, because guests rarely care about a specific 7:30am tee time; they care about playing on Saturday. When any cancellation opens a slot on that day, all waiting guests are notified via a webhook event (which can trigger a customer email). The system also tracks whether notified guests go on to book. A separate event fires based on the conversion evaluation, giving operators real visibility into whether the waiting list is driving revenue.

The feature is off by default per venue, because enabling it changes the guest experience, and that should be a deliberate choice, not a default. When an operator later disables it, they choose whether to keep existing entries or cancel them. A small detail that prevents surprising guests who were already waiting.

Feature governance

Features like the storefront, waiting list, payments, and email can be toggled per tenant with optional locking. Usage is tracked and aggregated monthly for billing. This matters because different venues are at different stages. A new venue testing GolfKit should not be exposed to every feature on day one.

6. Speed & Freshness

A modern booking experience means real-time availability: guests see what is open, it updates as others book, and nothing feels stale. The engineering challenge is that the upstream API enforces a rate limit of roughly 200 requests per minute. A single customer browsing a date consumes one request. A busy resort with dozens of concurrent browsers would exhaust the budget in seconds, leaving nothing for bookings, the one operation that must never be rate-limited. The worst failure mode would be: background polling consumes the entire call budget and then real bookings start failing.

I initially considered polling at different fixed frequencies by date horizon (today more often, next month less). The key insight was that freshness should follow demand, not the calendar. A date three days from now with 50 people browsing it matters more than tomorrow with zero interest. That led to a three-part system: Stale-While-Revalidate for instant responses, demand-aware polling to concentrate freshness where it matters, and a budget governor to stay within the rate limit.

Stale-While-Revalidate

When a guest requests tee times, the system checks Redis first. Fresh data is returned immediately. Stale data is returned immediately and a background refresh is triggered. Only on a complete cache miss does the guest wait for a live API fetch. The vast majority of browsing requests resolve in single-digit milliseconds.

Demand-aware polling

The system tracks demand per date in a sliding 10-minute window with per-IP deduplication (so bots and monitoring scripts cannot inflate the signal). Dates are classified into demand levels:

Cold1× base interval
Warm2× frequency
Hot4× frequency
Peakmax freshness

Dates are also tiered by day offset (today through 90+ days), with progressively longer base intervals further out. Beyond 90 days, data is fetched only on demand. The effect is that API budget automatically concentrates on the dates guests care about, and because demand drives the polling, the system is inherently revenue-aligned: busy dates stay fresh because they are the ones likely to convert.

Budget governor

A 1-second tick loop continuously recalculates available polling budget: the rate-limit ceiling minus recent API calls minus a safety margin reserved for bookings. When budget is tight, the scheduler sheds low-priority polls. When budget is healthy, it catches up on overdue work. Startup polls are staggered across 3 minutes to avoid a cold-start spike. The result is a self-regulating system that maximises freshness within a hard rate limit without ever starving bookings of API capacity.

Real-time updates

Caching solves the speed problem, but availability changes between page loads. Every time the poller fetches fresh data, a diff detector compares the new state against the previous cache. Changes are published via Redis Pub/Sub and streamed to connected browsers via Server-Sent Events (SSE). Multiple browser connections share a single Redis subscription per channel, with heartbeats to keep connections alive through proxies and automatic reconnection with gap recovery.

Systems thinking: Caching, polling, and budget governance are not three separate features. They form a closed loop: demand drives polling frequency, polling consumes API budget, the governor constrains polling, and the cache absorbs the difference. Changing any one parameter ripples through the others. The system works because the parts are designed to regulate each other.

7. Booking Orchestration

Browsing is a read problem. Booking is a write problem, and a harder one, because it must coordinate multiple systems and guarantee that partial failures do not leave the system in an inconsistent state. An orphaned order in a downstream system is worse than no order at all.

Lock
Validate
Execute
Compensateon failure only
Record

Before any booking begins, the orchestrator acquires an optimistic lock on the tee-time slot using Redis SET NX with a 120-second TTL. This prevents two guests from booking the same slot simultaneously. The system then validates that enough slots remain and the price has not changed since the guest saw it.

Lightspeed is executed first and alone. If the golf reservation cannot be confirmed, the booking fails immediately with no side effects. Once it succeeds, remaining adapters (packages, future integrations) execute concurrently. If any adapter fails after others have succeeded, the orchestrator runs compensation: every completed order is cancelled automatically. No booking can leave the system in an ambiguous state.

Each external system is represented by an adapter implementing placeOrder and cancelOrder. Adding a new system (a different POS, a spa, an equipment rental service) requires only a new adapter. The orchestrator, compensation logic, event recording, and webhook dispatch all work unchanged.

An idempotency layer prevents duplicate bookings on retry: a Redis state machine tracks each booking intent through pending, committed, or failed states with a 24-hour retention window, so clients can safely retry without risk of double-charging.

8. Webhooks & Integrations

Resorts need to integrate with CRM systems, email platforms, analytics dashboards, and accounting software. Building custom integrations for each would be expensive and fragile. I wanted anything that happens on the system to be easily pushed to another system, so I built a comprehensive events and webhook layer rather than point-to-point integrations.

Unified event log

Every significant domain action (booking created, cancellation completed, voucher issued, waiting-list entry notified, check-in recorded) is written to an immutable, append-only events table with JSONB payloads. New event types do not require migrations. This is the single source of truth for what happened in the system, and it feeds the webhook dispatch directly.

Outbound dispatch

Operators configure webhook destinations per venue: a URL, a set of enabled event types, and a signing secret. Events are queued in Redis and processed by a background worker. Each delivery is signed using HMAC-SHA256 in a Stripe-compatible format, so operators can verify authenticity using a scheme they likely already know. Failed deliveries retry with exponential backoff; persistent failures move to a dead-letter queue. Every delivery attempt is logged.

Webhook URLs are validated against a comprehensive SSRF blocklist with a DNS rebinding check at dispatch time. Signing secrets support rotation with a 24-hour dual-signature grace period.

Email as a webhook consumer

I also built a separate email microservice for customer notifications (booking confirmations, cancellation receipts, waiting-list alerts). Resorts may not want to do the work of integrating webhooks to an email system (even if it is just Zapier), so this was built as a standalone service that simply consumes webhooks exactly the way any other third-party system would. It is not coupled to the middleware. It receives the same signed events that any external subscriber receives. Each venue can configure its own email provider and sender address, and templates are editable per event type.

Architecture thinking: The email service is proof that the webhook system works. If GolfKit’s own notification service is just another webhook consumer, then building external integrations is straightforward by design, not by documentation promise.

9. Payments & Refunds

Each venue can configure its own Stripe credentials, stored encrypted alongside their Lightspeed tokens. A platform fallback key is available for venues that have not yet onboarded their own Stripe account. The architecture is designed to support Stripe Connect in a future phase, which would allow the platform to take a small application fee on each transaction rather than charging a monthly subscription, a model where revenue scales with the venue’s success and the barrier to adoption stays low.

When a booking is cancelled, the refund settlement depends on the venue’s configured policy:

Cash only

Refund goes directly to the guest’s payment method via Stripe.

Voucher only

A credit voucher is issued. GolfKit records the issuance and dispatches a webhook event; the downstream system handles code generation and delivery.

Guest choice

The guest selects their preferred refund method at cancellation time.

A tiered refund policy allows operators to define rules based on timing: full refund if cancelled 48 hours ahead, partial refund at 24 hours, voucher or no refund on the day. Each tier supports both cash and voucher actions with configurable percentages. The cancellation is never blocked by a payment system failure. Refund settlement catches up asynchronously using a write-ahead log.

It is worth noting that the Lightspeed Partner API v2 does not expose a refund or payment reversal endpoint. This is a constraint of the upstream system, not a design choice. GolfKit handles refunds through Stripe directly, independently of Lightspeed.

10. Resilience & Infrastructure

The system depends on Redis, PostgreSQL, and the upstream Lightspeed API. Each will eventually be unavailable, and the system must continue to operate. The design principle is: protect the checkout flow, fail loudly on admin tools. A customer’s booking should degrade gracefully. An admin operation with a silent failure could corrupt data, so it is better to surface the error clearly.

Graceful degradation

1Redis unavailableCaching disabled, polling moves to in-memory, booking locks fail safely
2Postgres unavailableWrites queue in a Redis write-ahead log, replayed on recovery
3Upstream API unavailableCircuit breaker opens, cached data served, bookings paused until recovery

The circuit breaker tracks API health per tenant. Rate-limit responses trigger a recovery sequence that resumes at 80% capacity for two minutes before restoring full throughput. Five consecutive 5xx errors open the circuit for 60 seconds. A single success clears the error streak.

When Postgres is unavailable, critical writes (booking logs, events, voucher issuances) are serialised to a Redis write-ahead log and replayed in order on recovery. If both Redis and Postgres go down, bookings can still proceed: Lightspeed and Stripe remain authoritative, and reconciliation acts as a backstop.

The service stack

1MiddlewareExpress API: caching, booking, package catalog, admin, webhooks (port 3002)
2Booking Reminder ServicePre-tee-time notification scheduler; listens for booking events and fires reminders at configured lead times (port 3005)
3Email ServiceTenant-branded transactional email via webhook consumption (port 3004)
4Stripe GatewayIsolated payment webhook ingress (port 8787)
5Redis 7Cache, pub/sub, booking locks, demand counters, write-ahead log
6PostgreSQL 16Durable audit trail, configuration, event store

Redis serves as the fast path: tee-time cache, booking locks, demand counters, webhook queue, pub/sub, and write-ahead log. PostgreSQL serves as the durable path: booking audit trail, cancellation records, event store, webhook delivery log, and all configuration. Package and commerce logic lives inside the middleware, sharing its infrastructure while remaining in its own route layer. The booking reminder service is separate because scheduling concerns have a different failure profile and release cadence from booking or cache logic.

The Stripe webhook gateway runs in isolation so that payment webhook failures cannot affect the booking API. Per-tenant credentials (Lightspeed OAuth tokens, Stripe keys, email provider secrets) are encrypted at rest using AES-256-GCM with per-tenant key derivation via HKDF. Key rotation is supported without downtime. Every credential access is audit-logged.

Structured JSON logging with per-request correlation IDs means a single booking can be traced from HTTP request through lock acquisition, API calls, adapter execution, event recording, and webhook dispatch. An in-process metrics system tracks cache hit rates, API pressure, booking latency percentiles (P50, P95), and fires alerts when thresholds are breached.

11. What Makes It Different

GolfKit is not a wrapper around an API. It is the layer between a system of record and a guest-facing operation, and the design decisions are shaped by two things: empathy for the guest’s journey, and the real constraints of the systems underneath.

The guest comes first

No account required. Real-time availability. Resume-on-return. Pricing in the framing the guest prefers. Branded from colours to copy. Check-in kiosks. Self-service management. Waiting list instead of a dead end. Every detail exists because the guest matters more than the system.

Operators get real control

Product curation without touching the system of record. Day and time-range blocking with bulk cancellation. Notices with guest acknowledgement. Waiting lists with conversion tracking. Custom subdomains and domains. Pause mode. Feature toggles. The admin surface covers what operators actually need to run a resort.

The webhook system is not decorative

GolfKit’s own email service is just another webhook consumer. If the platform’s own notification system integrates via the same mechanism as external subscribers, then downstream integration is straightforward by design. Events are immutable, deliveries are signed and logged, and the dead-letter queue means nothing is silently lost.

Constraints shaped the architecture

Lightspeed’s rate limit created demand-aware caching. The lack of a refund API created Stripe-direct settlement. The absence of webhooks created the events table. The need for multi-tenancy created per-tenant encryption and scoped access. Every major infrastructure decision traces back to a real constraint, not an abstract principle.

Concerns are genuinely separated

Package and commerce logic lives inside the middleware but in its own route layer, keeping it isolated without the overhead of a separate service. The email service is separate because it is just a webhook consumer. The booking reminder service is separate because its polling and scheduling logic has nothing to do with booking or cache stability. The Stripe gateway is isolated so payment failures do not crash the booking API. These are not microservices for the sake of it. Each separation exists because the failure modes and release cycles are different.

Creating an excellent guest experience requires sustained attention to detail and genuine empathy for customer journeys. Features like the waiting list, guest checkout, and session resilience each address a moment where the system could lose a guest, and choose not to.

The focus right now is on delivering the best possible experience on Lightspeed, without forcing resorts to change their systems, while giving them something meaningfully better for their guests.

Technical reference → for implementation detail on the architecture, data model, API surface, caching formulas, adapter interfaces, and deployment.