The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the Kicksmash listing page.
Mobile-first web app for organizing padel matches with zero app installs, zero accounts, zero passwords. Create a match, share kicksma.sh/{code} on WhatsApp or Telegram, friends tap → enter a name once → they're in.
/p/{token}, shown on My matches, in every email and in the calendar invite) that signs any device in; an email that was used before can restore history with a 6-digit code, merging all identities that share it. The home-screen shortcut opens the personal link, and calendar entries and emails carry the private event link (/p/{token}/{code}: signs the device in, opens the match). A newly added email receives the personal link (inside the calendar invite when in a match, otherwise on its own). Tokens are 12 characters; older 32-char tokens keep working as previous_token after the lazy shortening. An email can be changed but never blanked once set; the previous address is kept as recovery_email, so a restore code sent to either address gets the player back in. "Email me this link" on My matches mails the personal link (native share, copy and QR are the other options)./api/cron/push is called every 5 minutes by Supabase pg_cron + pg_net (Vercel Hobby cron is daily). Set VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT./{code} (4 chars, public) · /{code}/i/{6} (personal invite) · /{code}/manage/{10} (organizer secret)./api/openapi.json), an MCP server at /mcp that any assistant adds by URL, instant self-serve keys, signed webhooks, llms.txt, /.well-known/mcp.json, a robots.txt that welcomes AI crawlers, AGENTS.md, an installable skill (npx skills add evhg/padel-matchup), calendar feeds per group and venue. Public data is CC BY 4.0. See /developers and /agents.RESEND_API_KEY the app runs fully with email features hidden./about (one faint footer link) says what is stored, what is never done, and how to leave. Every organizer-initiated email (invites, invite reminders) carries a signed one-tap /unsubscribe link; a player adding their own address again lifts the opt-out. "Delete my account" at the bottom of My matches wipes personal data, releases upcoming spots, cancels the player's own upcoming matches, and — if they coach — cancels their upcoming lessons (each student told, with the coach's real name) and takes their coach page down with everything on it; old scores stay as "Deleted player". A coach who wants only the page gone, keeping the account, has "Delete my coach page" at the bottom of their settings, which refuses while a lesson is still to come.metrics_daily, no extra infrastructure. Hitting one returns "too many" and nothing else happens.That's it. With no DATABASE_URL the app boots an embedded PGlite database in ./.pglite, applies migrations and seeds two example events:
| URL | What you get |
|---|---|
| http://localhost:3000/PLAY | Upcoming match: 2 joined, 1 reserved invite, 1 open spot |
| http://localhost:3000/PAST | Finished match with an organizer-confirmed 3-set score |
| http://localhost:3000/new | Create your own |
Copy .env.example to .env to change anything. All flows (join, waitlist, invites, scores, "My matches", OG previews) work end-to-end without any keys.
pnpm e2e boots next start on port 3001 with a throwaway PGlite database, a dummy Resend key (email UIs on, sends fail harmlessly) and generated VAPID keys, then runs every e2e/*.mjs suite. SHOTS=./shots keeps full-page screenshots; PW_CHROMIUM=/path/to/chromium uses a preinstalled browser. GitHub Actions runs typecheck, lint, vitest on PGlite and on a real Postgres service, the build, and the e2e suites on every push and pull request (.github/workflows/ci.yml).
Tests run on in-memory PGlite by default. To run them against a real Postgres (true concurrency), point TEST_DATABASE_URL at a disposable database — its public schema is dropped before each test file:
Only one variable is required in production: the database URL. Everything else has a safe default.
| Variable | Required | Purpose |
|---|---|---|
DATABASE_URL | ✅ | Supabase Transaction pooler string (port 6543), exactly as Supabase's Connect dialog shows it. POSTGRES_URL (the Vercel ⇄ Supabase integration) and SUPABASE_DB_URL work too. Empty → the embedded PGlite database, for local development only. |
DATABASE_PASSWORD | if the URL still says [YOUR-PASSWORD] | Substituted into the URL and percent-encoded for you. |
DIRECT_DATABASE_URL | no | Direct (port 5432) URL for pnpm db:migrate and pnpm db:generate. POSTGRES_URL_NON_POOLING works too. |
AUTO_MIGRATE | no | false stops the app applying migrations on its first connection. That safety net is for a fresh database only: production gets each migration from the Migrate workflow (AGENTS.md rule 7). |
APP_BASE_URL | no | Defaults to the Vercel production domain. Set it locally and on other hosts. NEXT_PUBLIC_APP_BASE_URL is the browser's copy of the same value. |
SESSION_SECRET | recommended | Signs the identity cookie. Without it a stable secret is derived from the database URL. |
CRON_SECRET | recommended | Protects /api/cron/* and the one-off setup routes. Vercel sends it automatically when set. |
RESEND_API_KEY | no | Enables every email: calendar invitations, notifications, reminders. |
EMAIL_FROM | no | Defaults to Kicksmash <matches@<your domain>>; the domain must be verified in Resend. |
RESEND_WEBHOOK_SECRET | no | Verifies Resend's inbound webhook, so a reply to feedback@ or claude@ becomes a note. |
OUTREACH_FROM | no | The From line on outreach mail. Defaults to Claude at Kicksmash <claude@<your domain>>. |
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT | no | Enables push reminders (npx web-push generate-vapid-keys). |
TELEGRAM_BOT_TOKEN / TELEGRAM_WEBHOOK_SECRET / TELEGRAM_BOT_USERNAME | no | Enables the Telegram bot and Telegram sign-in. Register the webhook once with GET /api/telegram/setup (Bearer CRON_SECRET). The username defaults to the one in src/lib/config.ts; the token is what decides whether there is a bot at all. |
TELEGRAM_MINIAPP_SLUG | no | The Mini App's short name, so cards can carry a direct link into it. Without it the bot's cards link to the web instead, which is right until the app exists. |
TELEGRAM_OWNER_ID | no | The owner's Telegram id: proposals, club claims and drafts go there for a one-tap answer, and the admin desks open for that account only. |
DISCORD_BOT_TOKEN / DISCORD_PUBLIC_KEY | no | Enables the Discord bot. Register the commands and the interactions URL once with GET /api/discord/setup (Bearer CRON_SECRET); it returns the install link. |
DISCORD_APPLICATION_ID | no | Read from the bot token unless set. |
DISCORD_INVITE_URL | no | Shows the server on the community pages. |
WHATSAPP_TOKEN / WHATSAPP_PHONE_ID / WHATSAPP_NUMBER / WHATSAPP_VERIFY_TOKEN / WHATSAPP_APP_SECRET | no | Enables the WhatsApp thread: one person at a time, never a card channel — Meta's Groups API makes only the business's own groups (eight people, invite link only, Official Business Account required), so a bot can never sit in the crew's chat. Point Meta's webhook at /api/whatsapp/webhook: the GET answers the subscription handshake with WHATSAPP_VERIFY_TOKEN, and every POST is refused unless signed with WHATSAPP_APP_SECRET. WHATSAPP_NUMBER is the number people message, and builds the wa.me line an organiser pastes into their own group. |
WHATSAPP_TEMPLATES_PER_DAY | no | How many message templates WhatsApp may send in one UTC day: a match's changes, a free spot, the score ask and the result card, to players whose channel is WhatsApp. Defaults to 50; 0 switches them off and those notices go by email or push. Meta bills each template it delivers outside an open 24-hour window, so this is the cost guard (docs/OPERATING.md). |
WHATSAPP_WABA_ID | no | The WhatsApp Business Account id (not the phone number id). Only scripts/whatsapp-templates.mjs reads it, to list the templates Meta holds and to create the missing ones from data/whatsapp-templates.json. |
LINE_CHANNEL_TOKEN / LINE_CHANNEL_SECRET | no | Enables the LINE bot: the card in a Thai crew's group chat. Point LINE's webhook at /api/line/webhook; every delivery is refused unless signed with LINE_CHANNEL_SECRET. LINE cannot edit a sent message, so the card is re-sent only when something a player would notice changed, and a reply (free) is used wherever an answer is possible instead of a push (metered). |
PASSPORT_PRIVATE_KEY / PASSPORT_PUBLIC_KEY | no | Ed25519 pair (raw 32-byte hex each) that signs player passports. Without them a passport carries alg: "none". |
ANTHROPIC_API_KEY | no | Drafts the replies on the listening desk and the proposal the owner receives for a note. |
LISTEN_MODEL | no | The model those drafts use. Defaults to claude-sonnet-5, which src/lib/ops/anthropic.ts also prices. |
ANTHROPIC_MONTHLY_CAP_USD | no | The ceiling the app keeps itself under, in US dollars. Defaults to 20. |
ANTHROPIC_ADMIN_KEY | no | An Admin API key, so the service board reads the real spend instead of its own estimate. |
TAVILY_API_KEY | no | The research desk: clubs and coaches per city, and grounding for answer pages. |
REDDIT_CLIENT_ID / REDDIT_CLIENT_SECRET / REDDIT_USERNAME / REDDIT_PASSWORD | no | Lets an approved reply be posted on Reddit as the project's account. Without them, Approve means copy and paste. |
GOOGLE_SERVICE_ACCOUNT_JSON | no | A service account with Search Console access, so the service board can read impressions and clicks. |
INDEXNOW_KEY | no | Tells Bing and Yandex a page changed, the moment it changes. |
BACKUP_GITHUB_TOKEN / BACKUP_GITHUB_REPO | no | The nightly database snapshot is committed to that repository. |
UPTIME_REPO | no | owner/repo of the GitHub Actions uptime probe, so the service board can read its open incidents. |
OPERATOR_VERCEL_TEAM / OPERATOR_VERCEL_PROJECT | no | Names the Vercel project, so a missing key can be reported with the link that sets it. |
Generate secrets: openssl rand -base64 32. The table above and .env.example are written by node scripts/gen-docs.mjs from the process.env reads in the code, and tests/docs.test.ts fails when either falls behind, so a variable the code reads cannot go undocumented. Check a deployment any time at /api/health (no secrets returned).
The walkthrough lives in docs/DEPLOY.md: Supabase, Vercel, a domain, Resend and the cron jobs, in a browser-only version and a CLI version. Nothing in it is needed to run the app locally.
The pure engines ship as packages, generated from src/lib/domain so there is one source of truth:
@erikv69/americano: buildSchedule({ names | players, courts, rounds, seed }) for a whole americano, plus the round-by-round planners (planRound, planMexicanoRound, planKingRound), histories and standings.@erikv69/levels: bands, presets, ranges and levelFit, balancedTeams, matchDeltas and tournamentDeltas.pnpm packages:build regenerates packages/*/src and packages/*/dist (both gitignored); tests/packages.test.ts builds them on every CI run. To release: bump the version in packages/<name>/package.json, build, npm publish from that directory.
Questions, ideas and "I built a thing on the API" go to GitHub Discussions. Bugs go to issues. There is a Discord server too (the link is on /developers), where the bot answers questions about once an hour. The Telegram bot and the Reddit account answer people where they are; the code and the roadmap live here.
Kicksmash is one Next.js project and one Postgres database, Apache-2.0. Run it for your club, your city or your country: docs/DEPLOY.md has the Vercel button, the Docker build and the walkthrough. The environment table above is the whole configuration, and a test keeps that true.
UPDATE … WHERE id = (SELECT … LIMIT 1) claims exactly one slot, so two taps on the last spot resolve cleanly (tested with 12 parallel joins).data/whatsapp-templates.json), at most WHATSAPP_TEMPLATES_PER_DAY a day; a template that cannot go falls through to email, then push. No Google/Apple buttons for a single match: those create unlinked copies we could never update. A chat player's calendar is instead a feed of their own matches (/p/{key}/calendar.ics, from thirty days back, the same UID and fields as the invitation, a cancelled match marked), offered as webcal:// and through Google Calendar on a computer; the key is a hash of the personal token, so the address signs nobody in. Invitation entries carry exactly one link, the short private event link (kicksma.sh/p/{12 chars}/{code}), plus the player list once complete; feed entries carry the public match link.- COMPLETE in the title and the player names in the description (and reverted if someone drops out). The app never messages anyone; organizers get one-tap WhatsApp / Telegram forward buttons with pre-filled localized text. Declined slots become open spots.x-vercel-ip-timezone (browser zone as fallback), editable, stored UTC./new hands out a create link bound to the chat; a pasted kicksma.sh link becomes a card when the bot is admin or privacy mode is off; /match CODE works always), one short "line-up complete" note, one reminder about an hour before (via the 5-minute push cron), and the result picture once the organizer confirms. English or Russian per chat (/lang). People who tap become players with just their first name; Telegram sign-in on My matches (Login Widget) links or merges that account with the browser identity. Module: src/lib/telegram/./new hands out a create link bound to the channel, /match CODE posts the card, the two buttons join and leave through the shared operations and the card updates in place for everyone; one "line-up complete" note, one reminder, the result once. /ask answers a padel or Kicksmash question on the spot, and the hourly tick reads new messages in the servers the bot is in, keeps the ones that read like questions and answers them in a reply (this is our own community, so no tap is needed; the shared daily budget still applies, and every reply grows an answer page). /lang en|ru per channel. Module: src/lib/discord/./admin/listen is the desk (owner only, via Telegram sign-in). Daily ceilings on drafts and tokens keep a capped API key safe. Approved replies grow into evergreen answer pages at /answers/{slug} (question rewritten generically, QAPage JSON-LD, in the sitemap), published at once; a Sunday digest on Telegram lists the week and offers one-tap Unpublish for each new page. Module: src/lib/listen/./feedback in Telegram or Discord, the web form at /feedback, or an email to feedback@ becomes a note. The person gets an honest thank-you at once (the note was read; they hear if something gets built; nothing else is promised). The owner gets a proposal on Telegram the moment a real note arrives: the verdict the rules in docs/DECIDING.md give, what would change and where, the size (small, medium, large), a timeline estimate made without reading the code, what it needs, and a recommendation, ending with "build" or "skip". Nothing is built from a note until the owner says so, and the person hears only what shipped. A production error the store has never seen is one line to the owner too, at first sight, a few an hour at most. The note and the error are the triggers; there is no daily loop./embed/board/{slug} and /embed/match/{code} are iframe-safe views (no header, opens on kicksma.sh in a new tab, "Live from kicksma.sh" footer); the venue board shows the snippet under "Embed this board". /api/oembed?url=…&format=json is an oEmbed provider and match and board pages advertise it with <link rel="alternate" type="application/json+oembed">, so WordPress, Discourse, Ghost and Notion unfurl a pasted link into the live card. Helper: src/lib/embed.ts.Dockerfile builds a standalone image; the README's environment table is the whole configuration, and a test keeps that true. Everything is Apache-2.0; run it for your club, your city or your country.src/lib/domain/formats.ts./v/{slug}/ranking ranks a club's finalized results from the last 90 days (3 points per win, 1 per draw, 3/2/1 for tournament podiums); /phuket and /singapore do the same across a city's clubs and list the open matches there. Only players who switched on "Show me in rankings" (My matches, or one tap on a ranking page) appear. Cities: src/lib/domain/cities.ts./clubs/claim (name, the city and country anywhere in the world, booking page, website, courts, a few lines about the club). The claim is self-serve and carries its own check: the claimant says their role at the club and gives a work contact; a work email at the club's own domain is confirmed by a 6-digit code on the spot, anything else the owner checks by hand; then one Telegram tap makes the page live, and the claimant hears the answer on Telegram or by email. Nothing of a pending claim shows to anybody but its claimant. Once live, /v/{slug} shows "Book on Playtomic / MATCHi / Playbypoint …" (the platform is recognised from the link, also on every match at that club), the website, the about text, the badges, and free courts today when the club shares a feed it already has: a calendar of bookings (.ics, free = courts − overlapping bookings inside opening hours) or a JSON list of free slots, refreshed hourly, nothing scraped, nothing we were not handed. A free court two to six hours ahead goes once to the players who asked to play at that club on that day and around that hour, on the channel they have, with one button to the match form at that club and hour (one court a day for each want). A refused claim on a club the directory listed puts the listing back as the directory has it, under a new manage link, unless the refusal says it is not a club or a duplicate. The first ten clubs per city are founding clubs: everything stays free for them for good. /clubs lists live clubs by city; GET /api/v1/clubs?city=phuket and /api/v1/clubs/{slug} expose the same publicly; the MCP tool is find_clubs. Clubs manage their page through a private link that also shows on My matches. Modules: src/lib/domain/clubs.ts, src/lib/booking/ (platform detection, availability adapters)./u/{slug} (first name, level with band and the organizer-confirmed tick, played / won / win rate / podiums, the clubs they play at) and gets a signed level document at /u/{slug}/passport.json: Ed25519 over canonical JSON, public key at /.well-known/kicksmash-passport.json, verifyPassport in @erikv69/levels, 90-day expiry. No list of profiles exists. Download my data (/api/me/export) is one JSON file with everything we hold, tokens and manage links excluded. Level import: the level picker folds a "Have a level in another app?" row that maps Playtomic (same 0–7), 1–10 club scales and five-category systems onto 0–7 in the open; the table is on /levels. Modules: src/lib/domain/passport.ts (pure, WebCrypto), src/lib/domain/profile.ts./{code}/card: a page whose link unfurls with a generated picture (score boxes with the winning side highlighted, or the top five of the table) in WhatsApp and Telegram, the picture itself to long-press and save, share buttons, and "Organize your own match" as the way in./g/{6 chars}) with the match's settings as defaults (venue, format, capacity, level range, time zone) and its players as members; the person who taps is the admin. Anyone with the link joins (name only); joining a group's match or accepting its invite makes you a member too. Any member creates the next match from the group page: the create form comes prefilled (including the next weekly slot), the match is linked back, and every other member gets an email and a push with their private link. Admins can set a weekly slot: the hourly cron creates the next match a few days ahead (lead time 1–14 days, default 5), seats the group's creator, notifies everyone, and never creates the same occurrence twice. Under the group's matches, once it has two scored matches in the last 90 days, a small season table: each member who played, by first name, with matches played, won and wins in a row (🔥 from three), most wins first. My matches lists your groups with the next match of each./v/{venue-slug} (a public page named after the venue, with spots left and level range), and the match shows an "On the … board" chip. /v/{slug}/poster is a one-page printable poster with a QR code to the board ("Scan for open padel matches at …"). The board's empty state and footer lead to the create form with the venue prefilled and the listing switched on. Slugs are ASCII-only and kept in sync when a venue is renamed; removing the venue unlists the match./americano is a public, indexable page running the same rotation engine in the browser: players (or pasted names), courts, rounds; exact rotation when the field is in fours, fair sit-outs otherwise; print stylesheet; "Run it live on Kicksmash" carries the schedule into a real tournament: the names travel with it (/?type=tournament&capacity=N&s=gen&names=…), the create form opens under "Your americano, live" and names the players it carried, and each one takes a reserved spot with an invite link the moment the tournament exists (seatNames). A pasted list is capped at 64 names, the field the engine stops at. Both generator pages carry the feedback door. robots.txt and sitemap.xml cover /, /americano and /about; personal, manage, invite, share and card pages stay out of the index.Everything public on the site is available to programs and assistants, and everything the create form does is one call away.
| Surface | Where |
|---|---|
| REST reads (no key) | GET /api/v1/matches/{code}, /api/v1/boards/{slug}, /api/v1/groups/{code}, /api/v1/schedule?players=8&courts=2 |
| REST writes (key optional, rate-limited per address without one) | POST /api/v1/matches, POST /api/v1/matches/{code}/join |
| Keys | POST /api/v1/keys → instant, shown once; Authorization: Bearer ks_live_… |
| Webhooks (key required) | POST /api/v1/webhooks with url, events, optional filter; signed X-Kicksmash-Signature: t=…,v1=…; retried with backoff by the hourly cron |
| MCP | POST /mcp (streamable HTTP, stateless JSON). Matches and groups: about_kicksmash, get_match, find_matches, get_group, generate_schedule, create_match, join_match, create_api_key. Clubs and series: find_clubs, find_series. Coaching: find_coaches, coach_slots, request_coach, book_lesson, cancel_lesson. Plus resources with the model reference and the OpenAPI document. tests/docs.test.ts fails when this row falls behind src/lib/api/mcp.ts. |
| Discovery | /llms.txt, /llms-full.txt, /.well-known/mcp.json, /api/openapi.json, /developers, /agents, robots.txt explicitly allows AI crawlers |
| Feeds | /g/{code}/calendar.ics, /v/{slug}/calendar.ics; a player's own, private /p/{key}/calendar.ics |
Public shapes (src/lib/api/serialize.ts) carry first names and levels only; emails, phones, personal tokens and manage links never appear. Errors are { error: { code, message, hint, status } }; every hint says what to do next. Limits live in src/lib/domain/ratelimit.ts.
server.json at the repository root is the manifest for the official MCP registry (namespace io.github.evhg); publishing is one command for the repository owner: mcp-publisher login github && mcp-publisher publish. The skill in skills/kicksmash/SKILL.md is picked up by the skills index from the public repository. Smithery, Glama and mcp.so accept the remote URL https://kicksma.sh/mcp through their web forms.
/admin is a public, read-only usage page: hero figure with a health row (database, email, push, both crons, errors today), stat tiles with sparklines, meters against the free-tier ceilings (Supabase 500 MB, Resend 3,000/month and 100/day), and 7/30/90-day trend charts (growth, joins, outbound messages, errors, database size, totals). Counters live in metrics_daily (bumped by the app, snapshotted by the hourly cron); no personal data is shown. Errors are counted, never described: server actions that throw, cron steps that fail and browser crash screens (/api/client-error, rate-limited) each bump a counter, so the health row is the only alerting there is. Vercel bandwidth has no public API on Hobby, so it links to the dashboard.
Schema changes: edit the file for that domain under src/db/schema/ → pnpm db:generate → commit drizzle/ → the
Migrate workflow applies it when it reaches main (AGENTS.md rule 7). bash scripts/check-migrations.sh fails when the two disagree.