Case Study
Coyotes and Candles: Building a Full-Service Creative Platform for a Two-Person Business
Scale
501
Commits
~65,700
Lines of TypeScript
93
Page routes
74
API routes
65
SQL schema files
5
Paid tools replaced
The Problem
My wife Alli and I run Coyotes and Candles - a two-person business offering tarot readings, D&D one-shots, private group campaigns, and an online community. Before this build, every piece of the business lived in a different tool:
- -Calendly for scheduling (no payment, no video link, no follow-up)
- -Zoom for video (manual links, no native context)
- -Patreon and Ko-fi for subscriptions (fragmented audiences)
- -Discord for community (disconnected from everything else)
Customers had to bounce between five tools to book a session, join a video call, and find the community. We needed a single platform that handled everything from booking through post-session follow-up.
What Was Built
One integrated platform with six distinct booking flows, two payment providers, embedded live video, a full virtual tabletop, a community system, and an admin dashboard - replacing all five tools.
Booking & Payments
- ·6 booking flows: tarot readings, one-shots, private groups, campaigns with deferred billing, gift cards, subscriptions
- ·Stripe for one-time payments and recurring subscriptions
- ·PayPal Subscriptions API with complete webhook lifecycle management
- ·20+ automated email templates: confirmations, reminders, failures, and recovery
- ·Real-time availability calendar with timezone awareness
- ·Automatic refunds for cancellations 48+ hours before session
- ·Self-service rescheduling and campaign seat reservations with deferred payment
- ·Gift cards with promo codes
Session Delivery (VTT)
- ·Embedded Agora live video rooms - no downloads required
- ·Full virtual tabletop: tokens, fog of war, freehand drawing, dice roller
- ·D&D 5e character sheets with full SRD compendium integration
- ·Shadowrun 4e character sheets with Chummer XML import
- ·GM-controlled music via YouTube
- ·CoyoteCloud: live DiceCloud write-back and two-way sync so character changes in DiceCloud appear in the VTT instantly
Community
- ·Discord-style channel system, campaign-gated channels
- ·Direct messaging, reactions, typing indicators
- ·Web push notifications
- ·Moderation tools and audit logs
- ·Optional Discord webhook mirroring
Admin Dashboard
- ·Booking and campaign management
- ·Player management with custom pricing and attendance tracking
- ·Session notes and recaps
- ·Revenue analytics and P&L view
- ·Google Search Console integration
- ·Error monitoring with deduplication
Screenshots
Technical Architecture
What I Learned
This one is our own business, so the feedback loop is short: if a booking flow breaks, Alli hears about it from the customer that evening. Most of what follows is in the changelog because it went wrong first.
A guard that cannot tell a renewal from a duplicate will block the renewal
Long video calls dropped at almost exactly four hours. Agora access tokens expire then, and the client renews by re-hitting the token route, but that route had an "already in this session" check and the caller's own live heartbeat tripped it. The renewal came back 409 and failed quietly, so Agora disconnected them mid-session. The fix was to let the client mark a request as a renewal, skipping the duplicate check while a genuine second join is still refused. The general shape is worth remembering: any guard that identifies callers by their presence will eventually be shown the legitimate caller twice.
An UPDATE that matches no rows is not an error
New meeting rooms silently forgot every setting: sheet system, grid defaults, mode. The deployed table was missing a column the insert wrote to, so creating the config row failed, and every later update then matched zero rows and reported success, because updating nothing is a perfectly valid outcome in SQL. Older rooms were fine, since they predated the column. Nothing in the stack was going to raise this on its own. A write path has to check that it actually changed something, or the failure stays invisible until someone mentions their settings never stick.
A blocked request looks like a bug, not a block
The Twitch chat overlay rendered perfectly, header and all, and never showed a single line. The CSP's connect-src listed Supabase, Agora and Stripe but not Twitch's IRC WebSocket, so the browser refused the connection before it opened. The same shape bit twice more: next/image quietly declining to render admin-entered banner URLs whose hosts were not in the allowlist, and a partner project tightening its row-level security, which would have broken an import that had only ever worked because those tables were world-readable. Allowlists fail closed and quietly, and the symptom always presents as your own feature being broken.
Check-then-write is a race; let the database settle it
Gift card codes were generated by selecting to see whether a code already existed and then inserting it, which is time-of-check to time-of-use and will collide eventually. It was replaced by an insert that retries on a unique-constraint violation, making the database the arbiter instead of the application. The same reasoning covers Stripe retrying a webhook: duplicate invoice events are now skipped via an idempotency key rather than by hoping they never arrive twice. Anything with a finite supply or an at-most-once effect wants the constraint, not the check.
Separate "does this exist" from "may you see it"
After a Supabase key rotation, members who had not logged in since were told "this session is not available" when joining a call. Their stored token was signed with the old key, so the row-level-security read returned zero rows, the route concluded the room did not exist, and the interface rendered that 404 as a missing session. The room was fine; the message was a lie. Existence lookups now run through the service client while authentication is checked separately, so a stale token produces a clean 401 and a login prompt. Conflating those two questions does not just weaken authorization, it makes your error messages wrong.