Skip to content

Case Study

Coyotes and Candles: Building a Full-Service Creative Platform for a Two-Person Business

Role: Solo Full-Stack Developer & Co-founderDuration: June 2026 (~8 days active development)Status: Live and accepting bookings
Next.js 16SupabasePostgreSQLStripePayPalAgoraUpCloud + pm2

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

FrontendNext.js 16 (App Router), TypeScript, Tailwind CSS
BackendSupabase (PostgreSQL, Auth, Realtime) with Row Level Security
DeploymentUpCloud VPS via pm2 + Caddy reverse proxy
PaymentsStripe (one-time + recurring), PayPal Subscriptions API
VideoAgora SDK - embedded rooms, no downloads
SecurityNonce-based CSP, atomic database operations, timing-safe comparisons
ComplianceGDPR-ready, Finnish VAT (25.5%), JSON-LD structured data, Finnish sole trader (Y-tunnus: 3572436-7)

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.

Links