The software backend for a multi-location coworking business. Try the self-guided tour flow — a web action mints a time-boxed door credential and a real unlock closes the loop in the CRM.
Self-guided tour scheduling & door access
Jordan Rivera
jordan.rivera@example.com
Self-guided tour · Basalt Cowork · Tomorrow at 2:00 PM MT
Locked
Faithful to the production flow: approval mints a time-boxed Kisi door credential (20-min window); the physical unlock fires an HMAC-SHA256-verified webhook that closes the loop in the CRM and notifies the team. A web action drives a real lock.
Contact activity
No activity yet.
Team feed
Notifications appear here.
How it's built
Production system: a Turborepo monorepo — Next.js apps, a shared typed Supabase layer (Postgres + RLS), and a pg-boss worker — running one coworking business end-to-end: multi-site marketing sites → lead capture → CRM → centralized inventory → occupancy/billing → physical door access via the Kisi API. This demo is a genericized, read-only replica; the tour flow reproduces the real state machine (pending approval → scheduled → showed up) with an HMAC-verified unlock webhook.
the case study
The demo above is a genericized window into a production monorepo that runs a real multi-location coworking business end to end: four marketing sites feeding one CRM, tour bookings that mint time-boxed credentials on a physical door lock, a membership and billing ledger, and an email platform built from scratch rather than rented. Five apps, six packages, and three services in one Turborepo, backed by 47 Postgres tables.
in practice
A prospect fills out a form on any of the business's marketing sites. The lead lands in one central CRM through an API-key-gated ingest endpoint that maps the source site to the right property, captures full UTM attribution, fires the correct auto-response, and starts an activity timeline. Staff work the pipeline from a single dashboard: new, touring, negotiating, closed.
When a tour is approved, the system calls the Kisi access-control API and mints a door credential valid from five minutes before the scheduled time to fifteen minutes after. The visitor walks up, unlocks the real front door from the email on their phone, and the unlock webhook closes the loop: attendance marked, timestamp stamped, team notified in chat. Nobody drives across town to open a door for a no-show.
Behind the front of house: a centralized inventory model keeps pricing and availability consistent between the public sites and internal management, an occupancy ledger tracks who rents what at what rate until when, and the email platform runs lifecycle flows (tour follow-ups, win-backs) as code.
architecture
Each marketing site POSTs into a shared ingest endpoint that authenticates by API key, maps sourceSite to property, preserves UTM parameters, and fires source-aware auto-responses. Every touch lands on a unified contact activity timeline stored as typed events with JSONB context.
An approved booking creates a Kisi group link scoped to a 20-minute window. The lock's unlock webhook is verified with constant-time HMAC-SHA256 comparison and filtered by event type, actor type, lock id, and success flag before it touches the database. Membership state literally drives a real lock.
A ~17-table ESP built on Resend and pg-boss. Every send passes a single chokepoint enforcing nine ordered gates, marketing and transactional mail ride separate API keys so a campaign can never burn the reputation password resets depend on, and consent is an append-only audit log with IP and user agent.
Properties own spaces, spaces own units, and each space declares its inventory model: pooled (hot desks with a quantity) or individual (named offices and rooms), with unit-level pricing overrides. The public sites and the operator dashboard read the same source of truth, so one edit updates both.
Who rents what, at what monthly rate, until when: start and end dates, notice dates, monthly or annual billing, and an active/ending/ended lifecycle. A computed view classifies every contact (active tenant, former tenant, prospect) from the ledger in real time.
The whole backend was repackaged off managed hosting onto a Mac mini: four pm2-supervised processes behind a Cloudflare Tunnel, with a self-hosted Matrix server for team notifications and an agent-executable runbook. The handover preserved 1,298 live contacts and their unsubscribe tokens with zero data migration.
engineering highlights
The scary part of wiring software to a door is the webhook. This one refuses everything it can't prove: signature verified with a constant-time compare, then filtered to exactly one event type, one actor type, and one lock before any state changes.
const expected = createHmac("sha256", secret).update(body).digest("hex");
const sigBuf = Buffer.from(signature, "hex");
const expBuf = Buffer.from(expected, "hex");
if (sigBuf.length !== expBuf.length) return false;
return timingSafeEqual(sigBuf, expBuf); // constant-time compareif (event.type !== "lock.unlock" || event.success !== true) return skip();
if (event.actor_type !== "GroupLink") return skip();
if (event.object_id !== KISI_LOCK_ID) return skip();
const booking = await getTourBookingByKisiLinkId(supabase, event.actor_id);
if (!booking) return skip();
await markTourShowedUp(supabase, booking.id, event.created_at);Every email in the system, marketing or transactional, passes one function. The gates run in order and each can stop the send: idempotency keys make worker retries safe, suppression logic distinguishes hard bounces (block everything) from complaints (transactional still allowed), and marketing mail gets RFC-8058 one-click unsubscribe headers built in. Five consecutive soft bounces auto-promote to a hard suppression.
/** 9 gates, in order:
* 1. Validate input 2. Idempotency pre-check (UNIQUE in DB)
* 3. Contact resolution 4. Global suppression (hard_bounce blocks all)
* 5. Contact status 6. Subscription + pause (marketing only)
* 7. Render template 8. Headers (List-Unsubscribe + One-Click)
* 9. Insert message -> Resend call -> update message */Instead of a drag-and-drop flow builder, flows are typed definitions: absolute delays from enrollment, exit predicates (contact converted), skip predicates (contact has a newer inquiry). Reviewable in a PR, testable, versioned in git.
export const tourFollowup = defineFlow({
slug: "tour_followup",
trigger: { type: "manual" },
steps: [
{ name: "day0_thanks", delayFromEnrollment: 0,
template: "tour_followup_thanks",
exitIf: contactConvertedSinceEnrollment,
skipIf: contactHasNewInquiry },
{ name: "day7_walkthrough", delayFromEnrollment: days(7), /* ... */ },
{ name: "day14_soft_deadline", delayFromEnrollment: days(14), /* ... */ },
],
})stack
Turborepo (5 Next.js apps, 6 packages, 3 services) · Supabase Postgres with RLS (47 tables, 37 migrations) · Kisi access-control API · Resend with Svix-verified webhooks · pg-boss job queue · self-hosted Matrix (Conduit) for notifications · pm2 + Cloudflare Tunnel on owned hardware.
The demo above runs on seeded data against read-only views; the production system runs the real business daily. The email platform's engine was later generalized into a shared package reused across a second multi-market client. More systems like this in the registry.