Skip to content

Latest commit

 

History

History
1728 lines (1296 loc) · 83 KB

DOCS.md

File metadata and controls

1728 lines (1296 loc) · 83 KB

Game Night Documentation

A complete guide to setting up, configuring, and using Game Night — your self-hosted platform for organizing game nights, poker tournaments, and group events.


Table of Contents


Quick Start

# Clone and start
git clone https://github.com/Isorgcom/GameNight.git
cd GameNight
cp config/config.example.php config/config.php
docker compose up -d --build

Open your browser to the server's address. Log in with:

  • Username: admin@localhost
  • Password: admin

You'll be prompted to change your password immediately.


Deployment

Requirements

  • Docker and Docker Compose
  • A server with ports 80/443 available (or a reverse proxy like Nginx Proxy Manager)

Docker Compose

# Start the application
docker compose up -d --build

# Stop
docker compose down

# Update after pulling new code
docker compose down && docker compose up -d --build

Important Directories

Path Purpose
www/ Web root (served by Apache)
config/config.php Database path configuration (gitignored)
db/ SQLite database storage
www/uploads/ Uploaded images and banners

File Permissions

The db/ and www/uploads/ directories must be writable by the web server (www-data). If you get HTTP 500 errors on a fresh deploy, check ownership:

chown -R www-data:www-data db/ www/uploads/

Reverse Proxy

The container connects to the npm_default Docker network for use with Nginx Proxy Manager. Configure your proxy host to point to the container.


First-Time Setup

After your first login as admin:

  1. Change your password — You'll be forced to do this on first login.
  2. Set your site name — Go to Admin > General and give your site a name.
  3. Set the timezone — Important for event times to display correctly.
  4. Upload a banner — Go to Admin > Appearance to add your logo and header image.
  5. Enable notifications — Admin > General > "Enable Notifications" toggle. Off by default.
  6. Configure email — Admin > Email tab. Set up SMTP so invite emails and reminders work.
  7. Invite your friends — Have them register, or create accounts in Admin > Users.
  8. Create your first event — Open the Calendar, click "+", and set up a game night.
  9. Delete the welcome post — The default post on the home page can be removed or unpinned once you're ready.

Admin Guide

Access admin features at Admin in the navigation bar (admin users only).

Dashboard

Overview of your site: total users, total events, total posts, and recent activity.

General Settings

Setting Default Description
Site Name Game Night Displayed in the nav bar and emails
Site URL (blank) Used in notification links. Set this to your public URL
Timezone UTC All event times are displayed in this timezone
Allow Registration On When off, the signup page is disabled
Allow User Events Off When on, non-admin users can create and manage their own events
Show Upcoming Events On Show/hide the upcoming events section on the landing page
Enable Calendar On Show/hide the calendar page and nav link
Allow Maybe RSVP On When off, only Yes/No RSVP options are available
Enable Notifications Off Master switch for all email/SMS/WhatsApp notifications

Search & Sharing

The landing page (shown to logged-out visitors when Show Landing Page is on) is the one page search engines are meant to find, and these settings control what they show for it. All of them live in Admin → Settings → General.

Setting Default Description
Search title Free Poker Tournament Timer & League Manager | Site Name The landing page <title> and the headline in search results (about 60 characters show in full)
Search description (a fixed sentence about the timer, RSVPs and leagues) The snippet under the headline and the text on shared links, up to 160 characters
Google Search Console verification (blank) Paste the token or the whole <meta> tag from Search Console's HTML-tag method; emitted on the landing page only
Bing Webmaster Tools verification (blank) The msvalidate.01 token; or import the site into Bing from Search Console and leave this blank
Share Image (blank, falls back to the header banner) The 1200×630 picture shown when a link to the site is shared; uploads are checked for the card shape

What the site does on its own: every public page carries a description, canonical URL and Open Graph tags; the landing page carries JSON-LD (Organization, WebSite, SoftwareApplication); /sitemap.xml lists the landing page, the three guides, the league directory, sign-up, terms and privacy with last-modified dates; robots.txt keeps the logged-in app out of crawlers; the login page is noindex.

League pages and search engines. A league's public page (/league/<slug>) is reachable by link the moment the owner turns it on, but it is not listed by search engines until the owner also ticks Let search engines list this page in the league's settings. Until then the page carries noindex,follow and stays out of the sitemap. Once listed it gains structured data for the league and its upcoming events. The directory at /league (names and counts only) is always indexable.

To get into Google after a deploy: Search Console → Add property (URL prefix https://your-site/) → HTML tag → paste the token above → Verify; then Sitemaps → submit https://your-site/sitemap.xml. Bing Webmaster Tools can import the property from Search Console in one step.

Appearance

  • Header Banner — Upload a wide image displayed across the top of every page. Adjust the display height with the slider.
  • Page Banner (Logo) — Upload a smaller logo shown in the navigation bar.

Supported formats: JPEG, PNG. Max size: 4 MB.

Users

  • View all users in an inline-editable grid. Change username, email, phone, role, preferred contact, and notes directly in the table.
  • Create users manually with a username, email, and password.
  • Import users from a CSV file. Imported users get a temporary password and must change it on first login.
  • Export users to CSV.
  • Bulk actions — Select multiple users to change their role or delete them.
  • Edit a user — Click the edit icon to open a full edit page with all fields.
  • The last admin account cannot be demoted or deleted (safety guard).

Events

View and manage all events in the system. Inline-edit titles, dates, and times. Delete events you no longer need.

Email Configuration

Configure SMTP to enable email notifications:

Field Example
SMTP Host smtp.gmail.com
SMTP Port 587
SMTP User you@gmail.com
SMTP Password your-app-password
From Address noreply@yourdomain.com
From Name Game Night
Encryption TLS

Use the Send Test Email button to verify your configuration. Test emails bypass the global notification toggle so you can test even when notifications are off.

SMS Configuration

Choose from four SMS providers:

  • Twilio — Account SID, Auth Token, From Number
  • Plivo — Auth ID, Auth Token, From Number
  • Telnyx — API Key, From Number
  • Vonage (Nexmo) — API Key, API Secret, From Number

Configure your provider credentials and use Send Test SMS to verify.

URL Shortener: Enable is.gd link shortening to keep SMS messages concise.

WhatsApp Configuration

Set up the Meta Business API for WhatsApp notifications:

  • Phone Number ID
  • Access Token
  • Verify Token (for webhook verification)

Configure the webhook URL in your Meta Business dashboard to point to https://yoursite.com/wa_webhook.php.

Activity Logs

View a chronological log of all actions: logins, event changes, RSVP updates, admin actions, and more. Use Clear Logs to wipe the history.

Connected Apps

A connected app is a separate site (FinalTable, the online tournament table, is the first) that lets people sign in with their Game Night account instead of creating another one. The app sends the browser to /connect.php, the normal Game Night login runs (verification gate, two-factor, lockouts, all unchanged), the member confirms once, and the browser returns to the app with a short-lived signed token carrying the username and, when they have one, the path to their profile photo. Passwords, emails and phone numbers never leave Game Night.

Site Settings → Connected Apps is where an admin:

  • Registers an app: a slug (2-32 lowercase letters, digits, hyphens; it becomes the token audience), a name shown on the confirm card, and the base URL players use to reach the app. The base URL is the only place a token will ever be sent: scheme, host and port must match exactly, so http:// and https:// are different apps and so are two ports.
  • Enables, disables or removes an app. A disabled or removed app's sign-in links get a 404 page on Game Night, and nothing is shared.
  • Sees the public signing key and its id. FinalTable fetches the key itself when its operator enters this site's address on its Operator page; the .env snippet here is for a headless setup. The private half never leaves the server; it is generated on first visit and stored encrypted.
  • Regenerates the keypair. Every connected app must then be given the new public key before its sign-in works again, so this is a deliberate act behind a confirmation.
  • Pastes the app's game key, for games played there. FinalTable's administrator makes it on FinalTable's Admin page under GameNight (Make a key, shown once); it goes in the app's Game key field here and is kept encrypted, shown afterwards only as Set or Not set. Test proves the address and the key with two requests that make nothing: a wrong key comes back with FinalTable's own sentence. Once an app has a key, a poker tournament can be marked Online at it (see Online Tables). Clearing the key stops new tables being set up there; games already running are unaffected.

Each sign-in is written to the activity log as sso_login app=<slug>, and the app's row shows its last sign-in. See Connected Apps: Sign-In Bridge under the API section for the wire contract.


Calendar & Events

The calendar is the heart of Game Night. It defaults to Week view with a toggle to switch to Month view. On a phone the week fits the screen and scrolls with the page, with the day header staying put under the nav.

Creating Events

  1. Click the + button on any calendar date, or the + Add Event button.
  2. Fill in the details:
    • Title (required)
    • Date (required)
    • Start Time — Defaults to the current time. Uses native time picker on all devices.
    • Duration — Auto-calculates end time.
    • Color — Pick from 7 colors to categorize events.
    • Description (optional)
    • Poker Game toggle — Enables the poker check-in dashboard for this event.
    • Don't Notify toggle — Suppress invite notifications for this save.
  3. Add invitees (see below).
  4. Click Add Event.

Recurring Events

When creating or editing an event, set a recurrence pattern:

  • Daily — Every day
  • Weekly — Same day each week
  • Monthly — Same date each month
  • Yearly — Same date each year

Set an optional end date for the recurrence. Individual occurrences can be deleted without affecting the series.

Inviting People

The edit modal has a dual-pane invite system:

  • Left pane: All registered users. Search by name, email, or phone.
  • Right pane: Invited users for this event.

On desktop, double-click to move users between panes. On mobile/tablet, single-tap works.

You can also add custom invitees (people without accounts) by clicking "+ Custom Invitee" and entering a username and email.

Event Managers

Admins and event creators can grant Manager access to invited users:

  • Toggle the purple Mgr switch next to any invitee's name.
  • Managers can edit the event, manage invites, see contact details, and access the poker check-in page.
  • Managers cannot create new events or grant manager access to others.

RSVP System

Invitees can respond to events in several ways:

  • From the calendar — Click an event and use the RSVP dropdown.
  • From email/SMS — Click the one-click RSVP link (no login required).
  • From the event view — Use the RSVP panel.
  • Self-signup — Logged-in users can join events they weren't originally invited to.

RSVP options: Yes, No, and optionally Maybe (configurable in admin settings).

Event creators are notified when someone changes their RSVP.

Share an invite link

For people the host has no contact details for, the event page's Share invite button (upcoming events, hosts only) opens a panel with a link to /invite.php?e=<id>&t=<token>:

  • Share… opens the phone's share sheet (AirDrop, Messages, WhatsApp and the rest). Where there is no share sheet, the button copies the link.
  • Show QR fills the screen with a QR code for the link and keeps the screen awake while it is up. The code is drawn in the site's style, rounded modules in navy with blue finder corners, like every QR code the site draws; the site icon (Admin → Settings → Site Icon / Logo) sits in the middle, and the code is made with extra error correction so it still scans.
  • People who join need my OK (on by default): people who join through the link wait in Pending Approval. With it off they go straight on the list, up to the event's capacity, then onto the waiting list. The event's own requires approval setting still applies.
  • Seats left on the panel, the QR card and the invite page count a seat as taken only by a guest who has answered Yes. An invited guest who has not answered, or said Maybe, does not hold one, so inviting more people than seats does not make the link read as full. The host's own waiting list in the editor keeps its rule: a guest placed beyond capacity there moves up when an invited guest says No.
  • Make a new link / Turn link off: the old link then says it is no longer active. People who already joined stay on the list. The walk-up QR uses its own token and is not affected.

The invite page shows the title, date and time, the venue's name, the host and how many seats are left, but not the street address, since a link can be forwarded to anyone. The address reaches a guest in their confirmation (or approval notice) through their own event link. A signed-out visitor gives a name and an email or phone. An account found by that address is always Pending, so nobody can confirm a spot for someone else by typing their email. A signed-in visitor joins with one tap. Ten sign-ups per address per hour, and 100 per event per day. Guests who joined this way are tagged via link in the host's Invites panel.


Poker Game Management

Toggle Poker Game on any event to unlock the check-in dashboard at /checkin.php.

Setting Up a Poker Game

  1. Create an event with the Poker Game toggle on.
  2. Click Manage Game from the event view.
  3. Choose your game type: Tournament or Cash Game.
  4. Configure the session:
    • Buy-in amount
    • Rebuy amount and whether rebuys are allowed
    • Add-on amount and whether add-ons are allowed
    • Starting chips (tournament)
    • Number of tables
  5. Click Create Session.

Tournament Mode

  • Check-in players as they arrive.
  • Buy-in tracks who has paid.
  • Rebuys and Add-ons — Increment/decrement counters per player.
  • Table Assignment — Assign players to numbered tables.
  • Elimination — Mark players as eliminated (records finish position).
  • Payouts — Configure percentage-based payout structure (e.g., 1st: 50%, 2nd: 30%, 3rd: 20%). Payout amounts auto-calculate from the total pool.

Cash Game Mode

  • Cash In — Track how much each player brings to the table. Add or subtract amounts.
  • Cash Out — Record what each player leaves with.
  • Profit/Loss — Auto-calculated per player (cash out minus cash in).

Managing Players

  • All event invitees auto-appear in the player list with their RSVP status.
  • Walk-ins — Add players who weren't on the invite list.
  • Notes — Add per-player notes (e.g., "owes $20 from last time").
  • Filters — Filter the list by status: All, RSVP Yes, Checked In, Playing, Eliminated.
  • RSVP sync — Changing RSVP on the check-in page syncs back to the event.

Game Settings

Click Settings during an active game to adjust:

  • Game type, buy-in/rebuy/addon amounts
  • Rebuy and add-on toggles
  • Max rebuys
  • Starting chips
  • Number of tables
  • Payout structure (add/remove places, adjust percentages)

Online Tables (FinalTable)

A poker tournament can be played on FinalTable, the online table, instead of in a room. It needs a connected app with a game key (see Connected Apps); the event editor's poker bar then offers Played: In person / Online at FinalTable, and once Online is chosen, beside it, Game and Betting. The games are FinalTable's: Hold'em, Omaha, Omaha Hi-Lo, Crazy Pineapple, Seven-Card Stud, Stud Hi-Lo, Razz, Five-Card Draw, and HORSE, which plays five of them in turn, one a level. Each comes up at the betting it is usually played at - Hold'em no-limit, Omaha pot-limit, the stud games and HORSE fixed-limit - and you can pick another. An event that never chose plays Hold'em no-limit, as every table did before there was a choice. The pick is named under Played online at FinalTable on the event page and in the seat invitation; once the table exists, the page says what FinalTable actually made. Cash games stay in person.

Setting the table up. The event page of an online event carries a Played online at FinalTable section. Before the table exists, the organiser (and any event manager) sees what will be sent and a Set up the table on FinalTable button:

  • Who goes. The organiser hosts the table, whether or not they are on their own guest list, and every approved invitee who has a Game Night account goes with them. FinalTable seats people by account, so an invitee without one is listed as not sent; give them an account and re-invite them. The guest list is the door: a listed member who signs in at FinalTable with their Game Night account walks straight in, and nobody else can.
  • What goes. The event's title and start time, the game and the betting, the session's blind schedule (from Blinds & timer display; the site's default preset when the game has none; FinalTable's own Standard structure when there is neither, or when the blinds are in cents, since FinalTable plays whole chips), starting chips (1000, 2000, 5000 or 10000 there), seats per table (eight at most; seven at a stud table or HORSE, whose rounds include stud), the buy-in, add-ons, and re-entry for the first three levels when rebuys are allowed. Where FinalTable makes something other than what was sent - the stack size, the seats, the game - the organiser is told when the button is pressed, and the note is kept in the game log.
  • When. FinalTable takes a game up to seven days ahead; the page says when to come back for one further out. A start in the past, or an all-day event, means the host starts the clock by hand.

Pressing the button makes the game, tells everyone on the guest list where to play (a notification with the join link, through their usual channels, under the changes category) and turns the section into the table's status: registering with its start, then the level and what it posts the way the game does - the blinds and ante, or a stud game's ante, bring-in and bets, and in HORSE which game the level is playing - the time to the next level, players left, and whether the host has paused. Play at FinalTable is the join link for people with a seat, Watch the rail link for anyone who can see the event; both have a copy button.

Watching is public, on purpose. The rail link seats nobody and anyone who has it may pass it on — the invitation email says so, and everyone on the guest list gets it there. Somebody watching sees the field by name, the chip counts and the table, the same as a player who has busted; they never see anyone's cards. Hide guest list covers the guest list on the event page, not the rail: if you do not want a night watched, do not hand the link out.

Driving the table. Managers get Start now (before the scheduled time; two people must be seated), Pause and Resume, Take out of play beside any seated player (their seat goes, they are told; during a hand it waits for the hand to end), and Cancel the game, which sends everyone back to the lobby and keeps the places so far. FinalTable refuses what its own host could not do, in its own words, and the page shows the sentence.

The record of the night. FinalTable reports back as the game runs, and this side writes what an in-person game would have written by hand: every bust-out becomes a finish position (provisional until late registration and re-entry close; the final standings are the last word), a re-entry clears it and counts as a rebuy, and the ending records every place, marks who actually played as bought in, sets the session to finished and computes payouts, points, bounties and entry tickets from this event's payout structure, exactly as Manage Game's Finish does. FinalTable's own prize figures are kept for the status line only. The league table picks the event up like any other. A cancelled game leaves the session as it stood. For an online game, let FinalTable keep the book: hand edits in Manage Game are overwritten by the standings when the game ends.

Afterwards. Once a game has finished or been called off, the organiser can set the table up again; the old game's stragglers are ignored. Deleting an event with a live table calls the game off first. Removing a member from a league, or deleting their account, signs them out of every FinalTable that has a key.


Posts & Announcements

Posts appear on the landing page as a news feed / bulletin board.

Creating Posts (Admin)

  1. Go to Admin > Posts or click New Post.
  2. Enter a title and use the rich text editor for content.
  3. Upload images directly in the editor (drag & drop or toolbar button).
  4. Optionally set a custom date/time.
  5. Publish the post.

Post Features

  • Pin — Pinned posts stay at the top of the feed.
  • Hide — Hidden posts are invisible to users but not deleted.
  • Edit — Change title, content, date, or pin status.
  • Delete — Permanently removes the post and its uploaded images.
  • Bulk Delete — Select multiple posts and delete them at once.

Comments

Both posts and events support comments.

  • Any logged-in user can leave a comment.
  • Users can edit or delete their own comments.
  • Admins can edit or delete any comment and use bulk delete.
  • Comments are plain text (max 2,000 characters).

User Guide

Registration & Login

  1. Go to the site and click Sign Up.
  2. Choose a username (3-30 characters, letters/numbers/underscores).
  3. Enter your email, optional phone number, and a password (8+ characters).
  4. Check your email for a verification link. Click it to activate your account.
  5. Log in with your email and password.

Profile Settings

Go to Settings (gear icon or nav menu) to:

  • Update your username, email, or phone number. Changing any of these asks for your current password. A new email takes effect only after you use the confirmation link sent to it (the old address is told a change was requested), and a changed phone number must be verified again before SMS two-factor will text it.
  • Change your password. This signs out every other device and browser where you were logged in; a password reset does the same.
  • Turn two-factor authentication on or off and regenerate recovery codes (your password is asked for each), and delete your account (type DELETE and your password).
  • Set your preferred contact method: Email, SMS, WhatsApp, Both (email + SMS), or None. A new account starts on SMS whenever a phone number is on file, email otherwise.

My Events

The My Events page shows all events you're invited to or created, split into Upcoming and Past. Each event shows your RSVP status and quick links to the calendar view.

RSVP from Email/SMS

When you're invited to an event, you'll receive a notification via your preferred contact method. The message includes a one-click RSVP link that works without logging in. After responding, you can change your mind using the alternate response buttons on the confirmation page.


Notifications

How Notifications Work

Notifications are sent via the user's preferred contact method (email, SMS, WhatsApp, or both). The admin must enable notifications globally in Admin > General > Enable Notifications.

Notification triggers:

  • Event invite — When you're added to an event.
  • RSVP change — Event creator is notified when someone RSVPs.
  • Reminders — Automated reminders before events (requires cron setup).
  • Password reset — Email with reset link.
  • Email verification — Confirmation link on registration.

Hosts can see every send and reply for an event under More → Message history on the event page: a timeline of send runs and replies (reminders still to go out on top, notices to the hosts folded away), or one thread per guest, with a Failed-only filter. The log keeps 90 days.

Reminder Schedule

When cron is configured, automatic reminders are sent:

  • 2 days before the event
  • 12 hours before the event

Each reminder is sent only once per user per event occurrence (tracked in the database).

Reminders go to every approved guest on the list who has not answered No. A guest without an account is reached at the email or phone on their invite, the same way their invitation was. Guests awaiting approval or waitlisted get none, a guest who said No is skipped when the reminder is due (an answer changed back to Yes or Maybe before then still gets it), and a guest removed or denied loses any reminders already queued for them.

SMS RSVP Replies

If you have SMS configured, users can reply to invitation texts with YES, NO, or MAYBE to update their RSVP. The system matches the reply to the user's phone number and updates their most recent pending invite.


Cron Setup

Automated reminders require a cron job that calls the reminder endpoint.

  1. In Admin > General, find or generate your Cron Token.
  2. Set up a cron job on your server:
# Run every 15 minutes
*/15 * * * * curl -s -H "X-Cron-Token: YOUR_CRON_TOKEN" "https://yoursite.com/cron.php" > /dev/null 2>&1

Or use the Docker container's built-in timer if configured in docker-compose.yml.


API for Sister Sites

GameNight exposes a small JSON API at /api/v1/ so a separate website (for example, a poker league's main marketing site) can pull league data, or push new users into a league, without copy-pasting. Each API key is bound to one league at issuance and is restricted to that league's data — keys cannot read or write across leagues.

The API was designed for a single trusted server-to-server consumer model (one shared key in the consumer's config). It is not an OAuth provider and does not have per-end-user tokens.

Scopes

Every key carries a scope:

  • read (default) — GET endpoints only.
  • read,write — In addition to read, allows write endpoints such as POST /users.

Old keys minted before the scope system shipped are migrated to read, so they cannot exercise write endpoints until they are re-minted with write access.

Issuing a Key

  1. As the owner of a league, navigate to that league's page (/league.php?id=N) and click the API tab. Site admins can also access this tab on any league. Managers cannot issue keys — issuing a key exposes the league's data outside the platform, which is an owner-level decision.
  2. Type a label that describes who's getting the key (for example westside-poker sister site).
  3. Pick a scope: Read-only (default) or Read + write (create users). Hand out the smallest scope that does the job — a read-only key that leaks cannot create accounts in your league.
  4. Click Mint key.
  5. The plaintext key is displayed exactly once in a green box. Copy it now and store it in the consumer's server config. The key is hashed (SHA-256) at rest; once you leave the page you cannot recover the plaintext.
  6. The key appears in the table below the form with scope, status, created date, and last-used date. You can revoke it at any time.

Site admins can see every key across every league via Admin Settings → API Keys. That page is read-only and lets admins revoke any key for abuse response, but admins cannot mint keys on behalf of league owners.

API Authentication

Pass the key in the Authorization header:

curl -H 'Authorization: Bearer YOUR_KEY' https://your-site.com/api/v1/league

If your client cannot set headers, you can pass the key as a ?key= query parameter instead. This works the same way but the key may show up in server logs and referer headers, so the header approach is preferred:

curl 'https://your-site.com/api/v1/league?key=YOUR_KEY'

Either way, the request must be over HTTPS in production.

API Endpoints

The base path /api/v1 (no trailing slash needed) returns a discovery document describing the available endpoints — useful for human exploration; no key required. All other endpoints require a key.

GET /api/v1/league

League summary.

{
  "ok": true,
  "data": {
    "id": 6,
    "name": "Kipling Poker",
    "description": "Friendly home game, every other Saturday.",
    "member_count": 13,
    "created_at": "2026-04-17 02:36:29"
  }
}

GET /api/v1/members

Roster. Personal contact info (emails, phones) is never returned.

{
  "ok": true,
  "data": [
    { "member_id": 1,  "user_id": 6,    "display_name": "Bryce",   "known_as": "Bryce K.", "role": "owner",   "pending": false, "joined_at": "2026-04-17 02:36:29", "invited_at": null,                  "invited_by_username": null },
    { "member_id": 4,  "user_id": 12,   "display_name": "brad",    "role": "manager", "pending": false, "joined_at": "2026-04-17 13:43:25", "invited_at": null,                  "invited_by_username": null },
    { "member_id": 17, "user_id": null, "display_name": "Crystal", "role": "member",  "pending": true,  "joined_at": "2026-04-25 23:06:29", "invited_at": "2026-04-25 23:06:29", "invited_by_username": "Bryce" }
  ]
}

known_as is the name the league's owner or manager gave the member on the Members tab (null when none); display_name stays the account's screen name.

pending: true means the person was invited by email or phone but has not yet created an account; for those rows user_id is null. Registered members get an integer user_id that you can pass back to write endpoints (e.g. as an invitee on POST /events/{id}/invites). The display name is their username if they have an account, otherwise the contact name on the invite.

member_id (the league_members PK) is on every row and is the only stable identifier for pending contacts. Pass it to PATCH /api/v1/pending-contacts/{member_id} and DELETE /api/v1/pending-contacts/{member_id} to edit or remove pending rows. invited_at and invited_by_username are typically populated only on pending rows.

GET /api/v1/members/{user_id}

Single league-member by user_id. Same shape as a list-item from GET /members. pending is always false because pending contacts have no user_id and aren't addressable through this endpoint. Returns 404 member_not_found if the user_id isn't a member of this league.

{
  "ok": true,
  "data": {
    "member_id": 4,
    "user_id": 12,
    "display_name": "brad",
    "role": "manager",
    "pending": false,
    "joined_at": "2026-04-17 13:43:25",
    "invited_at": null,
    "invited_by_username": null
  }
}

PATCH /api/v1/members/{user_id}

Requires the write scope. Promote or demote a registered league member's role.

Request body (JSON):

Field Type Notes
league_role string, required One of member, manager. owner is not settable via the API — privilege transfer is UI-only (use the in-app "transfer ownership" flow).

Idempotent: if the member's role already matches the requested value, the response returns role_changed: false and no DB write happens.

Successful response (HTTP 200):

{
  "ok": true,
  "data": {
    "league_id": 6,
    "user_id": 12,
    "league_role": "manager",
    "role_changed": true
  }
}

Error responses:

HTTP code Meaning
400 Invalid body, unknown field, cannot_set_owner_via_api (you tried to promote to owner), or cannot_demote_owner (the target is currently owner).
401 Missing, malformed, or revoked API key.
403 API key lacks the write scope.
404 member_not_found — the user_id isn't a member of this league, or the membership row is a pending contact (no registered account yet — call POST /users first).
429 Rate limit exceeded — 60 updates per hour per key.

Example:

# Promote a member to manager
curl -X PATCH -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     -H 'Content-Type: application/json' \
     -d '{"league_role":"manager"}' \
     https://your-site.com/api/v1/members/12

# Demote a manager back to plain member
curl -X PATCH -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     -H 'Content-Type: application/json' \
     -d '{"league_role":"member"}' \
     https://your-site.com/api/v1/members/12

DELETE /api/v1/members/{user_id}

Requires the write scope. Removes a user from the bound league.

The user account is not deleted — only their league_members row is dropped. Their RSVPs to existing events, their per-event manager roles (event_invites.event_role='manager'), authored posts, and memberships in other leagues all remain intact. The user can be re-added later via POST /api/v1/users with the same email/phone (idempotent) or by an admin in the league UI.

The removed user is notified by their preferred channel: subject "Removed from {league_name}", body "You were removed from the league \"{league_name}\".". The notification is best-effort — a failed SMS or email does not roll back the removal. The response's notification_sent field reflects whether the send succeeded.

Successful response (HTTP 200):

{
  "ok": true,
  "data": {
    "league_id": 9,
    "user_id": 240,
    "removed": true,
    "notification_sent": true
  }
}

Error responses:

HTTP code Meaning
400 cannot_remove_owner — the league owner cannot be removed via the API. Use the in-app Transfer ownership flow first; the new owner can then be removed normally.
401 Missing, malformed, or revoked API key.
403 API key lacks the write scope.
404 member_not_found — the user_id isn't a member of this league (also returned for users in other leagues, to avoid confirming their existence).
429 Rate limit exceeded — 60 removals per hour per key.

Examples:

# Remove a member
curl -X DELETE -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     https://your-site.com/api/v1/members/240

# Re-add them later (idempotent on email/phone)
curl -X POST -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     -H 'Content-Type: application/json' \
     -d '{"display_name":"Brenda","email":"brenda@example.com"}' \
     https://your-site.com/api/v1/users

PATCH /api/v1/pending-contacts/{member_id}

Requires the write scope. Edit a pending contact — a league_members row where user_id IS NULL, i.e. someone who was invited by email or phone but hasn't claimed an account yet. The display name, email, and phone for those rows live on the league_members table itself, so editing them is genuinely league-scoped — no cross-league effects, no rewriting a real user's login.

To address pending rows, use member_id from GET /api/v1/members. Registered members (rows with a non-null user_id) cannot be edited here — use PATCH /api/v1/members/{user_id} for those.

Request body (JSON, at least one field required):

Field Type Notes
display_name string, optional Trimmed; max 200 chars. Cannot be empty after trim.
email string, optional Validated and lowercased before storage. Pass an empty string to clear. Must be unique among pending contacts in the league.
phone string, optional Normalized to XXX-XXX-XXXX for US 10-digit numbers. Pass an empty string to clear.

The row must keep at least one of email or phone after the edit; clearing both returns 400 must_keep_email_or_phone.

Invite token regeneration: When email or phone changes, the row's invite_token is automatically regenerated. The old /league_invite.php?token=... link dies immediately. The new token is included in the response as invite_token so sister sites can re-deliver the invite. Old tokens are never echoed back. No-op edits (where every field already matches the stored value) leave the token alone.

Successful response (HTTP 200):

{
  "ok": true,
  "data": {
    "member_id": 17,
    "fields_changed": ["email"],
    "token_regenerated": true,
    "invite_token": "7b3f9a1c4e5d6f8a2b1c0d9e8f7a6b5c"
  }
}

Error responses:

HTTP code Meaning
400 Invalid body, unknown field, bad email format, must_keep_email_or_phone, email_already_pending, or not_a_pending_contact (the row is a registered member, not a pending contact).
401 Missing, malformed, or revoked API key.
403 API key lacks the write scope.
404 pending_contact_not_found — the member_id doesn't exist or belongs to a different league.
429 Rate limit exceeded — 60 updates per hour per key.

Example:

# Fix a typo in a pending contact's email — the invite link regenerates
curl -X PATCH -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     -H 'Content-Type: application/json' \
     -d '{"email":"crystal@example.com"}' \
     https://your-site.com/api/v1/pending-contacts/17

DELETE /api/v1/pending-contacts/{member_id}

Requires the write scope. Hard-delete a pending-contact row. Silent — pending contacts have no account to notify, and the contact info might be why you're deleting in the first place.

Registered rows are not addressable here even if you happen to pass their member_id; the response is the same 404 pending_contact_not_found so the API doesn't confirm row types.

Successful response (HTTP 200):

{
  "ok": true,
  "data": {
    "member_id": 17,
    "deleted": true
  }
}

Error responses:

HTTP code Meaning
401 Missing, malformed, or revoked API key.
403 API key lacks the write scope.
404 pending_contact_not_found — the member_id doesn't exist, belongs to a different league, or addresses a registered member (use DELETE /members/{user_id} for those).
429 Rate limit exceeded — 60 deletions per hour per key.

Example:

curl -X DELETE -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     https://your-site.com/api/v1/pending-contacts/17

GET /api/v1/events?from=YYYY-MM-DD&to=YYYY-MM-DD

Breaking change in v0.19208. This endpoint used to return start_date / start_time / end_date / end_time as local-time strings in the league's display timezone. It now returns ISO-8601 UTC instants in start_at / end_at. Sister sites no longer need to know the league's timezone to display events correctly.

Events for the league within a date window. RSVP counts only include approved invites.

Query param Default Notes
from today (in the league's timezone) Inclusive
to from + 90 days Inclusive; window is capped at 366 days
include_cancelled off Set to 1 to include events a host cancelled. Left out by default, so a schedule built from this list never shows a game that is off.

Every event carries cancelled_at: null, or the UTC instant the host cancelled it. A cancelled event is kept on the site (so it can be duplicated) but takes no changes: PATCH on it and any invite write answer 409 event_cancelled. DELETE still removes it, and tells nobody again.

{
  "ok": true,
  "data": {
    "from": "2026-04-28",
    "to": "2026-07-27",
    "count": 2,
    "events": [
      {
        "id": 67,
        "title": "Kipling poker 17th",
        "description": "",
        "start_at": "2026-05-17T20:00:00Z",
        "end_at":   "2026-05-18T02:00:00Z",
        "color": "#2563eb",
        "is_poker": true,
        "rsvp_yes_count": 5,
        "rsvp_no_count": 1,
        "rsvp_maybe_count": 0,
        "cancelled_at": null,
        "created_at": "2026-04-26T20:06:26Z"
      }
    ]
  }
}

start_at and end_at are ISO-8601 UTC instants ending in Z. All-day events (events scheduled with a date but no time) return a date-only string ("2026-05-17") in the same field instead of a full instant — this is how callers can tell the two apart. end_at is null for events without a configured end. created_at is also UTC.

GET /api/v1/events/{id}

Single event by id. Useful when you have just an id (e.g. stored after POST /events) and don't want to pull a date window and filter client-side.

Same shape as a list-item from GET /events, plus league_id and visibility for symmetry with the POST /events response. Returns 404 event_not_found if the id doesn't exist or belongs to a different league (the API doesn't confirm existence of resources outside the bound league).

{
  "ok": true,
  "data": {
    "id": 67,
    "title": "Kipling poker 17th",
    "description": "",
    "start_at": "2026-05-17T20:00:00Z",
    "end_at":   "2026-05-18T02:00:00Z",
    "color": "#2563eb",
    "is_poker": true,
    "league_id": 6,
    "visibility": "league",
    "rsvp_yes_count": 5,
    "rsvp_no_count": 1,
    "rsvp_maybe_count": 0,
    "cancelled_at": null,
    "created_at": "2026-04-26T20:06:26Z"
  }
}

GET /api/v1/events/{id}/invites

Invitee list with RSVP state. Sort order matches the calendar UI (COALESCE(sort_order, 999999), username). Personal contact info (email, phone, rsvp_token) is never returned.

{
  "ok": true,
  "data": {
    "event_id": 67,
    "count": 3,
    "invitees": [
      { "user_id": 6,    "display_name": "bryce",   "rsvp": "yes",   "approval_status": "approved",   "event_role": "manager" },
      { "user_id": 12,   "display_name": "brad",    "rsvp": null,    "approval_status": "approved",   "event_role": "invitee" },
      { "user_id": null, "display_name": "crystal", "rsvp": "maybe", "approval_status": "waitlisted", "event_role": "invitee" }
    ]
  }
}
  • user_id is null for custom invitees that were added by email/phone without a registered account. Sister sites usually only need the rows where user_id is non-null (those round-trip to GET /members).
  • rsvp is null until the invitee responds.
  • approval_status is one of approved, pending, waitlisted, denied.
  • event_role is invitee or manager.

Per-occurrence override rows from the legacy recurring-events feature are filtered out — only base invites surface.

GET /api/v1/posts?limit=20&offset=0

League posts (announcements / news). Excludes hidden posts, drafts, future-scheduled posts, and the league's rules post. Sorted by pinned, then created_at descending.

Query param Default Notes
limit 20 Max 50
offset 0 For pagination
{
  "ok": true,
  "data": {
    "total": 12,
    "limit": 20,
    "offset": 0,
    "count": 2,
    "posts": [
      {
        "id": 7,
        "title": "90min Turbo Blinds",
        "content_html": "<h1>8-Player 90-Minute Tournament</h1>...",
        "author_display_name": "Bryce",
        "created_at": "2026-04-27 20:39:53",
        "share_url": "https://your-site.com/post_public.php?token=5de463f570b59b21da4d67c1351fb4ad"
      }
    ]
  }
}

content_html is sanitized HTML (the same pipeline used when posts render in the UI). Posts that have a public share link include share_url; posts without sharing enabled omit that field.

GET /api/v1/posts/{id}

Single post by id. Same shape as a list-item from GET /posts. Same visibility filters: hidden posts, future-scheduled posts, and the rules post all return 404 post_not_found (use GET /rules for the rules post specifically).

{
  "ok": true,
  "data": {
    "id": 7,
    "title": "90min Turbo Blinds",
    "content_html": "<h1>8-Player 90-Minute Tournament</h1>...",
    "author_display_name": "Bryce",
    "created_at": "2026-04-27 20:39:53",
    "share_url": "https://your-site.com/post_public.php?token=5de463f570b59b21da4d67c1351fb4ad"
  }
}

share_url is included only when the post has a public share token configured.

POST /api/v1/posts

Requires the write scope. Creates a post in the API key's league. Author is set to the league owner. Content is sanitized through the same pipeline the in-app editor uses (script tags, event handlers, untrusted iframes, etc. stripped before storage).

Request body (JSON):

Field Type Notes
title string, required Max 200 chars.
content string, required Sanitized HTML; must be non-empty after sanitization.
pinned boolean, optional Default false. Pinned posts sort above unpinned.
hidden boolean, optional Default false. Hidden posts are not returned by GET /posts or GET /posts/{id}.
published_at string, optional ISO-8601 UTC instant ("2026-05-17T20:00:00Z"). Defaults to the server's current UTC time. Future values create a scheduled post — the row is in the database but the existing GET /posts filter (created_at <= now) keeps it hidden until the scheduled time arrives.

Locked fields: is_rules_post, share_token, and make_public are not settable via the API. Promoting a post to the league's rules post and minting a public share link both stay UI-only operations. Sending any of those fields returns 400.

Successful response (HTTP 200): same shape as GET /posts/{id}, plus pinned and hidden.

{
  "ok": true,
  "data": {
    "id": 23,
    "title": "Saturday rotation update",
    "content_html": "<p>We're moving start time to 7:30...</p>",
    "author_display_name": "bryce",
    "created_at": "2026-05-02 14:32:01",
    "pinned": false,
    "hidden": false
  }
}

PATCH /api/v1/posts/{id}

Requires the write scope. Partial update of an existing post. Only fields present in the body are touched.

Request body (JSON):

Field Type Notes
title string, optional Max 200 chars.
content string, optional Sanitized on write.
pinned boolean, optional
hidden boolean, optional

Locked fields: is_rules_post, share_token, make_public, and published_at are rejected with 400. Retroactive publish-date edits create a confusing audit story; the others remain UI-only.

Empty body or all-fields-unchanged returns 400 no_fields_to_update.

Successful response (HTTP 200):

{
  "ok": true,
  "data": {
    "id": 23,
    "title": "Saturday rotation update",
    "content_html": "<p>Updated body...</p>",
    "author_display_name": "bryce",
    "created_at": "2026-05-02 14:32:01",
    "pinned": true,
    "hidden": false,
    "fields_changed": ["content", "pinned"]
  }
}

fields_changed lists the columns that actually moved. If you POST a field whose value already matches the stored value, it does not appear here.

Examples:

# Pin an existing post
curl -X PATCH -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     -H 'Content-Type: application/json' \
     -d '{"pinned":true}' \
     https://your-site.com/api/v1/posts/23

# Hide a post (soft-removes it from feeds without deleting)
curl -X PATCH -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     -H 'Content-Type: application/json' \
     -d '{"hidden":true}' \
     https://your-site.com/api/v1/posts/23

DELETE /api/v1/posts/{id}

Requires the write scope. Hard-deletes a post in the API key's league. Cascades to comments (rows in the comments table where type='post' and content_id matches). Wrapped in a transaction; partial failures roll back cleanly.

Successful response (HTTP 200):

{
  "ok": true,
  "data": {
    "post_id": 23,
    "deleted": true,
    "comments_deleted": 4
  }
}

Error responses:

HTTP code Meaning
401 Missing, malformed, or revoked API key.
403 API key lacks the write scope.
404 post_not_found — the id doesn't exist or belongs to a different league.
429 Rate limit exceeded — 60 deletions per hour per key.

Example:

curl -X DELETE -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     https://your-site.com/api/v1/posts/23

GET /api/v1/rules

The league's rules post. The rules post is a special post (one per league at most) that lives behind a dedicated UI button in-app and is excluded from /api/v1/posts; this endpoint is the way to read it.

{
  "ok": true,
  "data": {
    "rules": {
      "id": 42,
      "title": "House Rules",
      "content_html": "<h2>Buy-in</h2><p>...</p>",
      "author_display_name": "Bryce",
      "created_at": "2025-11-12 03:14:00"
    }
  }
}

When the league has not configured a rules post yet, rules is null:

{ "ok": true, "data": { "rules": null } }

content_html is sanitized HTML, same pipeline as /posts. Hidden rules posts are treated as absent.

POST /api/v1/users

Requires the write scope. Creates a user and adds them to the key's league. Mirrors the walk-in registration flow: a soft account is created with must_change_password=1 and email_verified=0, and (unless suppressed) a verification email or SMS is sent so the new user can later set a password and sign in.

The endpoint is idempotent on email/phone — replaying the same request body returns the existing user_id, ensures league membership, and skips the verification send. Sister sites can retry safely without creating duplicate accounts.

Request body (JSON):

Field Type Notes
display_name string, required Used to derive a username when username is omitted.
email string, optional At least one of email or phone is required.
phone string, optional Normalized to XXX-XXX-XXXX for US numbers; international numbers stored as entered.
username string, optional 3–30 chars, letters/numbers/underscores. If omitted, derived from display_name with a numeric suffix on collision.
verification_method string, optional One of email, sms, whatsapp, none. One-shot — used only at signup. Default: email if email provided, else sms. Use none if your site handles onboarding itself.
preferred_contact string, optional One of email, sms, whatsapp, both, none. Sets the user's ongoing notification channel (the same setting they'd pick on /settings.php). Default: sms when a phone is given (whatsapp when that is the verification_method), else email. Ignored on existing-user replays so a leaked write key cannot mute or re-route real accounts.

verification_method is consulted only at signup; preferred_contact is what the system reads every time it sends a notification afterwards. They can differ — e.g. verify by SMS but prefer email going forward.

Successful response (HTTP 200):

{
  "ok": true,
  "data": {
    "user_id": 245,
    "username": "API_Test",
    "created": true,
    "league_member_added": true,
    "verification_sent": true,
    "preferred_contact": "email",
    "preferred_contact_updated": true
  }
}
  • created is true when a new user row was inserted, false when an existing user with that email or phone was found.
  • league_member_added is true when the user was newly added to this key's league, false when they were already a member.
  • verification_sent is false for existing-user replays and when verification_method=none.
  • preferred_contact echoes the resolved value (caller-supplied or default). For existing-user replays, it's the user's current stored preference.
  • preferred_contact_updated is true only when a new user was created. Always false on replays — preferences on existing accounts are intentionally not overwritten.

Error responses:

HTTP code Meaning
400 Invalid request body (missing display_name, no email or phone, malformed values, unknown verification_method or preferred_contact).
401 Missing, malformed, or revoked API key.
403 API key lacks the write scope.
409 username_taken (when caller passed an explicit username that's in use) or contact_taken (UNIQUE constraint race on email or phone).
429 Rate limit exceeded — 60 successful creations per hour per key.

Examples:

# Create with email
curl -X POST -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     -H 'Content-Type: application/json' \
     -d '{"display_name":"Alice","email":"alice@example.com"}' \
     https://your-site.com/api/v1/users

# Create with phone (sends SMS code)
curl -X POST -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     -H 'Content-Type: application/json' \
     -d '{"display_name":"Bob","phone":"281-555-1234","verification_method":"sms"}' \
     https://your-site.com/api/v1/users

# Suppress verification — your site handles onboarding
curl -X POST -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     -H 'Content-Type: application/json' \
     -d '{"display_name":"Carol","email":"carol@example.com","verification_method":"none"}' \
     https://your-site.com/api/v1/users

POST /api/v1/events

Requires the write scope. Creates an event in the API key's league. Visibility is forced to 'league'; league_id is implicit. The event's creator is set to the league owner so the event has a real manager. A walk-in token is generated immediately and returned as walkin_url so sister sites can show a QR right away. Side effects mirror the in-app calendar form: optional poker_sessions row, invitee inserts (always approved), beyond-capacity poker invitees marked waitlisted, reminder notifications queued.

Request body (JSON):

Field Type Notes
title string, required Max 200 chars.
start_at string, required ISO-8601 UTC instant ("2026-05-17T20:00:00Z") or a date-only string ("2026-05-17") for all-day events.
end_at string, optional Same format as start_at.
description string, optional Plain text.
color hex string, optional One of #2563eb, #16a34a, #dc2626, #d97706, #7c3aed, #0891b2, #db2777. Default #2563eb.
is_poker boolean, optional Default false. When true, a poker_sessions row is auto-created and the waitlist applies.
requires_approval boolean, optional Default false. Gates self-signups via walk-in / RSVP; on a league event a walk-in joins the league only once approved. Does not affect API-supplied invitees (those are always approved).
rsvp_deadline_hours integer, optional Hours before start_at when RSVPs lock.
waitlist_enabled boolean, optional Default true. Only meaningful when is_poker=true.
reminders_enabled boolean, optional Default true.
reminder_offsets array of integers, optional Minutes before start_at for each reminder send. Defaults to the site default (typically [2880, 720] = 48h and 12h).
poker_buyin number, optional Dollars (e.g. 20.00). Used only when is_poker=true.
poker_tables integer, optional Default 1.
poker_seats integer, optional Default 8. Capacity = poker_tables * poker_seats.
poker_game_type string, optional 'tournament' or 'cash'. Default 'tournament'.
invitees array, optional Each entry: {user_id: int, manager?: bool}. Each user_id must already be a member of this league (call POST /users first to create + add them). All inserted with approval_status='approved'. Capped at 200.

Successful response (HTTP 200):

{
  "ok": true,
  "data": {
    "event_id": 67,
    "title": "Kipling poker 17th",
    "start_at": "2026-05-17T20:00:00Z",
    "end_at": "2026-05-18T02:00:00Z",
    "league_id": 6,
    "visibility": "league",
    "is_poker": true,
    "walkin_url": "https://your-site.com/walkin.php?event_id=67&token=ABCD1234...",
    "invitees_added": 2,
    "created_at": "2026-04-30T17:23:11Z"
  }
}
  • walkin_url is the public registration link (the same URL the in-app QR code generates). You can show it as a QR on a check-in screen, share it in your event description, or print it.
  • invitees_added counts the rows actually inserted. If is_poker=true and waitlist_enabled=true, invitees beyond poker_tables * poker_seats are inserted with approval_status='waitlisted' (still counted in invitees_added).

Error responses:

HTTP code Meaning
400 Invalid request body. Examples: missing title, unparseable start_at, unknown color / recurrence, recurrence_end missing when recurrence is set, invitee user_id not in this league.
401 Missing, malformed, or revoked API key.
403 API key lacks the write scope.
404 The league bound to the key was deleted.
405 Method not allowed.
429 Rate limit exceeded — 60 successful event creations per hour per key.

Examples:

# Single-evening poker night
curl -X POST -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     -H 'Content-Type: application/json' \
     -d '{
       "title": "Friday $20 NLH",
       "start_at": "2026-05-22T23:00:00Z",
       "end_at":   "2026-05-23T03:00:00Z",
       "is_poker": true,
       "poker_buyin": 20,
       "poker_tables": 2,
       "poker_seats": 8
     }' \
     https://your-site.com/api/v1/events

# All-day calendar marker, no poker
curl -X POST -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     -H 'Content-Type: application/json' \
     -d '{"title":"League holiday","start_at":"2026-12-25"}' \
     https://your-site.com/api/v1/events

# With invitees (must already be league members; POST /users first if not)
curl -X POST -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     -H 'Content-Type: application/json' \
     -d '{
       "title": "Members-only tourney",
       "start_at": "2026-06-01T00:00:00Z",
       "is_poker": true,
       "invitees": [
         {"user_id": 12},
         {"user_id": 34, "manager": true}
       ]
     }' \
     https://your-site.com/api/v1/events

PATCH /api/v1/events/{id}

Requires the write scope. Partial update of an existing event. Only fields present in the body are touched — omit a key to leave it unchanged.

Accepts the same field shape as POST /events, with these exceptions:

  • invitees is not supported — use POST /api/v1/events/{id}/invites to add invitees.
  • league_id and visibility are immutable — events stay in the league bound to the API key.
  • recurrence / recurrence_end rejected (same as POST).

When start_at (date or time) changes and the event is in the future, an event_updated notification is queued for every approved base invitee. Other field changes are silent. The reminder queue is rebuilt automatically when timing or reminder fields change. Wrapped in a transaction; partial failures roll back cleanly.

Successful response (HTTP 200):

{
  "ok": true,
  "data": {
    "event_id": 67,
    "title": "Kipling poker 17th",
    "start_at": "2026-05-17T20:00:00Z",
    "end_at": "2026-05-18T02:00:00Z",
    "is_poker": true,
    "fields_changed": ["start_date", "start_time"],
    "notifications_queued": 4
  }
}

fields_changed is the list of stored columns that actually moved. If you POST a field that already matches the stored value, it does not appear here. notifications_queued reports how many event_updated notifications were queued (0 for past events or non-timing edits).

Error responses:

HTTP code Meaning
400 Invalid body, unparseable start_at, unknown color, no_fields_to_update (every field in the body matches the stored value), etc.
401 Missing, malformed, or revoked API key.
403 API key lacks the write scope.
404 event_not_found (id missing or in a different league).
429 Rate limit exceeded — 60 updates per hour per key.

Examples:

# Move start time
curl -X PATCH -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     -H 'Content-Type: application/json' \
     -d '{"start_at":"2026-05-17T21:00:00Z"}' \
     https://your-site.com/api/v1/events/67

# Polish the description (silent — no notifications)
curl -X PATCH -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     -H 'Content-Type: application/json' \
     -d '{"description":"Bring snacks. Doors at 7."}' \
     https://your-site.com/api/v1/events/67

# Bump poker capacity
curl -X PATCH -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     -H 'Content-Type: application/json' \
     -d '{"poker_tables":2,"poker_seats":9}' \
     https://your-site.com/api/v1/events/67

POST /api/v1/events/{id}/invites

Requires the write scope. Add invitees to an event after creation. Idempotent — anyone already invited is silently skipped and reported in the response.

Request body (JSON):

Field Type Notes
invitees array, required Each entry: {user_id: int, manager?: bool}. Each user_id must already be a member of this league (use GET /members to discover ids, or POST /users to create new members first). Capped at 200.

Newly-inserted rows always land approval_status='approved', mirroring how the calendar UI treats creator-added invites. For poker events with waitlist_enabled=true, anyone added beyond poker_tables * poker_seats is automatically marked waitlisted after insert and skips the invite notification.

Existing invitees keep their manager flag. The endpoint does not promote/demote on duplicate input. If you want to change someone's role on an event, that's a separate operation that isn't yet exposed.

Successful response (HTTP 200):

{
  "ok": true,
  "data": {
    "event_id": 67,
    "added": [12, 34],
    "skipped": [56],
    "waitlisted": [],
    "notifications_queued": 2
  }
}
  • added lists user_ids that produced new invite rows.
  • skipped lists user_ids that were already invited.
  • waitlisted lists user_ids whose new invite landed waitlisted (poker capacity overflow).
  • notifications_queued is the count of invite notifications fired — equals len(added) - len(waitlisted).

Error responses:

HTTP code Meaning
400 Empty invitees, malformed entries, user_ids not members of this league, or too many invitees.
401 Missing, malformed, or revoked API key.
403 API key lacks the write scope.
404 event_not_found (id missing or in a different league).
429 Rate limit exceeded — 60 calls per hour per key.

Example:

curl -X POST -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     -H 'Content-Type: application/json' \
     -d '{"invitees":[{"user_id":12},{"user_id":34,"manager":true}]}' \
     https://your-site.com/api/v1/events/67/invites

DELETE /api/v1/events/{id}/invites/{user_id}

Requires the write scope. Removes a single invitee from an event. Symmetric with POST /events/{id}/invites. Mirrors the calendar UI's remove_invitee action: for future events, queues a cancel_event notification to the removed user before the row is deleted; past events delete silently.

The wording of the notification is "this event has been cancelled" — the same template the full-event delete uses. It's awkward when only one user is being uninvited, but it matches what the in-app UI does today and saves adding a separate template.

Successful response (HTTP 200):

{
  "ok": true,
  "data": {
    "event_id": 67,
    "user_id": 12,
    "removed": true,
    "notifications_queued": 1
  }
}

notifications_queued is 1 for future events, 0 for past events.

Error responses:

HTTP code Meaning
401 Missing, malformed, or revoked API key.
403 API key lacks the write scope.
404 event_not_found (event id missing or in a different league) or invitee_not_found (user_id isn't currently invited). The two share the response shape but differ in the error field.
429 Rate limit exceeded — 60 removals per hour per key.

Example:

curl -X DELETE -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     https://your-site.com/api/v1/events/67/invites/12

PATCH /api/v1/events/{id}/invites/{user_id}

Requires the write scope. Update an invitee's rsvp or event_role without removing and re-adding them. At least one of the two fields must be present in the body.

Request body (JSON):

Field Type Notes
rsvp string or null, optional One of "yes", "no", "maybe", or null to clear.
event_role string, optional "invitee" or "manager".

The API acts as the league owner, so the 1-hour-before-start RSVP cutoff that applies to non-admin users in the UI does not apply here — sister sites can record same-day RSVPs without restriction.

When rsvp becomes "no" on a poker event with waitlist_enabled=true, the waitlist is recomputed automatically. Any waitlisted invitees promoted to approved are reported in promoted_from_waitlist.

Notifications: None are sent. The in-app UI doesn't notify either when an RSVP is recorded — sister sites that want to ping the host or the user should do so themselves.

Successful response (HTTP 200):

{
  "ok": true,
  "data": {
    "event_id": 67,
    "user_id": 12,
    "fields_changed": ["rsvp"],
    "promoted_from_waitlist": 1
  }
}

fields_changed lists the columns that actually moved. If you POST a field whose value already matches the stored value, it does not appear here. Empty body or all-fields-unchanged returns 400 no_fields_to_update.

Error responses:

HTTP code Meaning
400 Invalid body, unknown field, bad rsvp/event_role value, or no_fields_to_update.
401 Missing, malformed, or revoked API key.
403 API key lacks the write scope.
404 event_not_found (event missing or in a different league) or invitee_not_found (user_id not currently invited to this event).
429 Rate limit exceeded — 60 updates per hour per key.

Examples:

# Record an RSVP collected off-platform
curl -X PATCH -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     -H 'Content-Type: application/json' \
     -d '{"rsvp":"yes"}' \
     https://your-site.com/api/v1/events/67/invites/12

# Promote someone to per-event manager
curl -X PATCH -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     -H 'Content-Type: application/json' \
     -d '{"event_role":"manager"}' \
     https://your-site.com/api/v1/events/67/invites/12

DELETE /api/v1/events/{id}

Requires the write scope. Hard-deletes an event in the API key's league. Mirrors the calendar UI's delete handler: future events queue cancel notifications to invitees before the row is destroyed, past events delete silently. Wrapped in a transaction so partial failures roll back cleanly.

The cascade clears all related rows:

Table What gets deleted
pending_notifications All queued notifications for this event are cleared first (so dead reminders for the now-cancelled event don't fire). Fresh cancel_event rows are then re-queued for invitees and sent right after the transaction commits.
comments All comments where type='event' and content_id matches
event_exceptions Any per-occurrence overrides for this event
event_invites All invite rows
event_notifications_sent The dedup audit trail
poker_sessions (+ poker_players, poker_payouts, timer_state) Explicitly deleted; SQLite is not configured to honor the schema's ON DELETE CASCADE foreign key.
events The event itself

Future-event detection: start_date >= today (in the league's display timezone). When true, every base invitee (rows with occurrence_date IS NULL) gets a cancel_event notification queued before the deletion runs. The notifications_queued field in the response tells you how many.

Successful response (HTTP 200):

{
  "ok": true,
  "data": {
    "event_id": 67,
    "title": "Kipling poker 17th",
    "deleted": true,
    "notifications_queued": 4
  }
}

Error responses:

HTTP code Meaning
401 Missing, malformed, or revoked API key.
403 API key lacks the write scope.
404 event_not_found — the id doesn't exist OR the event belongs to a different league. The two cases return the same response on purpose (the API does not confirm existence of resources outside this key's league).
429 Rate limit exceeded — 60 deletions per hour per key.
500 Internal error during the cascade — the transaction was rolled back; the event was not deleted.

Examples:

# Cancel an upcoming event (will notify invitees)
curl -X DELETE -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     https://your-site.com/api/v1/events/67

# Clean up a past event (silent — no notifications)
curl -X DELETE -H 'Authorization: Bearer YOUR_WRITE_KEY' \
     https://your-site.com/api/v1/events/42

API Response Shape

Every response uses the same envelope:

  • Success: {"ok": true, "data": ...} — HTTP 200.
  • Error: {"ok": false, "error": "human-readable message"} — HTTP 400/401/403/404/405/409/429.

This matches the shape used by every internal _dl.php endpoint, so if you've integrated against any of those before, the parser is the same.

API Errors

HTTP code Meaning
400 Bad parameter or invalid request body. Examples: from after to; window over 366 days; non-hex characters in the key; missing display_name on POST /users.
401 Missing, malformed, or revoked API key.
403 API key lacks the scope required by the endpoint (e.g. a read-only key calling POST /users).
404 The league bound to the key was deleted (key is dead, mint a new one for a different league).
405 Method not allowed for this endpoint.
409 Conflict on POST /users — username_taken or contact_taken.
429 Per-key write rate limit exceeded (60 successful user creations per hour).

API Caching

Successful responses include Cache-Control: public, max-age=60. Consumers should cache for at least one minute. The data does not change every second; hammering the API with one request per page view is wasteful.

CORS is allowed from any origin (Access-Control-Allow-Origin: *), so a JavaScript client running in a browser can call the API directly. Note: putting the key in browser-side JS exposes it to anyone who views your page source. Do that only if you accept the consequences of revocation when (not if) the key leaks.

There is no hard rate limit yet, but every call is logged with the key id, IP, path, and status. Abusive patterns will result in the key being revoked.

API Examples

PHP (server-side, recommended):

$key = 'YOUR_64_CHAR_HEX_KEY';
$ctx = stream_context_create([
    'http' => [
        'header' => "Authorization: Bearer $key\r\n",
        'timeout' => 5,
    ],
]);
$resp = file_get_contents('https://your-site.com/api/v1/events', false, $ctx);
$data = json_decode($resp, true);
if ($data['ok'] ?? false) {
    foreach ($data['data']['events'] as $event) {
        echo htmlspecialchars($event['title']) . "<br>";
    }
}

JavaScript (browser, only if the key can be public):

fetch('https://your-site.com/api/v1/posts?limit=5', {
    headers: { 'Authorization': 'Bearer YOUR_KEY' }
})
.then(r => r.json())
.then(({ ok, data, error }) => {
    if (!ok) { console.error(error); return; }
    data.posts.forEach(p => console.log(p.title));
});

curl one-liners (handy for testing):

curl -H 'Authorization: Bearer YOUR_KEY' https://your-site.com/api/v1/league
curl -H 'Authorization: Bearer YOUR_KEY' https://your-site.com/api/v1/members
curl -H 'Authorization: Bearer YOUR_KEY' 'https://your-site.com/api/v1/events?from=2026-01-01&to=2026-12-31'
curl -H 'Authorization: Bearer YOUR_KEY' 'https://your-site.com/api/v1/posts?limit=5'
curl -H 'Authorization: Bearer YOUR_KEY' https://your-site.com/api/v1/rules

API Revoking a Key

If a key leaks, click Revoke on its row in the league's API tab. Consumers using that key start getting 401 responses immediately. The revocation is permanent (soft-delete: the row stays in the database with revoked_at set, so audit logs continue to make sense).

To rotate a key: mint a new key with a different label, update the consumer to use the new key, then revoke the old one. Keys do not expire on their own — rotate them on whatever cadence makes sense for your operation (annual is reasonable for a low-traffic sister site).

Connected Apps: Sign-In Bridge

Separate from the league API keys above. A connected app (registered under Site Settings → Connected Apps, see the Admin Guide) can sign a Game Night member in without holding a password: it hands the browser to Game Night and receives a signed identity token back. No API key is involved, and the only thing the app needs from Game Night is the public signing key.

Outbound (app → Game Night), a browser navigation:

GET /connect.php?app=<slug>&return=<url>&state=<nonce>
  • app — the registered slug.
  • return — where to send the browser back to. Validated origin-exact against the app's registered base URL (scheme, host, port), with the path under the registered path, no fragment, no credentials, 512 characters at most. Anything else renders an error page; Game Night never redirects to an unvalidated URL.
  • state — [A-Za-z0-9_-]{16,128}, minted by the app, echoed back unchanged. It is the app's login-CSRF guard.

A guest is sent through login.php?redirect=... first (email/phone verification and two-factor included). A signed-in member sees one card, "Continue to app as username?", with Continue, Cancel and Switch account.

Inbound (Game Night → app), a 302 after the confirm:

<return>#gn_token=<jwt>&state=<nonce>          success
<return>#gn_error=cancelled&state=<nonce>      the member pressed Cancel

The token rides in the URL fragment, so it never reaches the app's access log or a Referer header; the app's page script reads it, scrubs it, and sends it to its own server once.

The token is a compact JWT, header {"alg":"ES256","typ":"JWT","kid":"<first 16 hex of sha256(public PEM)>"}, signature the raw 64-byte R||S. Claims:

Claim Value
iss the site URL (Site Settings → General → Site URL; falls back to the request host, so set it)
aud the app slug
sub the member's user id, as a string
iat, exp issued at, and 120 seconds later; one hop, then it is spent
jti 32 hex, random; the app rejects a repeat
name the username
tier the account tier (Free, Personal, League, OriginalSupporters); informational
avatar_path the member's profile photo as a site-relative path (/uploads/avatars/u<id>_<32 hex>.<ext>), or null - absent when they have no photo, and absent when the file is over 512 KB

No email, phone or password is carried. The profile photo is carried as a path, never as image data: the app fetches it from this site the way any visitor would, and a member with no photo sends null.

The public key is on the Connected Apps page and at:

GET /api/v1/sso

No API key. Returns {issuer, connect_url, token: {format, claims, carried}, keys: [{kid, kty: "EC", crv: "P-256", alg: "ES256", use: "sig", pem}]}, cacheable for five minutes. An app verifies locally: pin alg to ES256 (refuse none and HMAC before reading the signature), check the signature with the PEM, then iss, aud, exp/iat with a minute of skew, and keep jtis until they expire.

Pairing FinalTable: register it here, then in FinalTable's lobby open Operator (behind its admin password), enter this site's address and the slug, and it fetches the key from /api/v1/sso itself. The .env snippet on the Connected Apps page is the headless alternative. FinalTable's own docs/DEPLOYMENT.md has the steps from its side.

Games on FinalTable (Game Night → app, then app → Game Night). The other direction of the bridge, for online tables. The contract is FinalTable's docs/API.md; what this side does with it:

  • Outbound. Every call carries the app's game key as Authorization: Bearer <key> (decrypted for the request, never logged) and reads FinalTable's {ok, data | error} envelope. Calls are made only by a person pressing a button: Test (GET /api/status, unkeyed, then GET /api/games/probe, whose 404 proves the key), Set up the table (POST /api/games with title, start_at, game, limit, seats_per_table, starting_chips, buyin_amount, addon_allowed, reentryLevels, blind_levels, structure_name, invitees[{user_id, username, manager}], webhook{url, secret} and external_id = the event id; the answer's settings say what was made, and finaltable_game_of() reads the game and betting from there once a table exists), the controls (POST /api/games/:id/start|pause|resume|cancel|remove), the status poll (GET /api/games/:id, cached ten seconds per game), and sign-out (POST /api/players/:user_id/sign-out). Redirects are never followed. There is no retry queue; a refusal is shown to the person in FinalTable's words.
  • Inbound. FinalTable POSTs each game event to /finaltable_webhook.php: JSON, headers X-FinalTable-Event, X-FinalTable-Delivery, X-FinalTable-Timestamp (milliseconds) and X-FinalTable-Signature: sha256=<hex>, an HMAC-SHA256 keyed with the secret this side generated for that game (48 hex characters, stored encrypted beside the game) over <timestamp>.<body>. The receiver takes the body once, finds the game by game.id, refuses a stale timestamp (five minutes) or a bad signature with 401 before reading anything else, answers 404 for a game it does not hold (FinalTable retries for a day, then gives up and logs it), and dedupes on (game, delivery_id) with a UNIQUE row that is the lock: a repeat answers 200 and is dropped. A heartbeat has no delivery id and skips the dedupe. A delivery this side fails to apply answers 500 so FinalTable tries again. Events handled: tournament.started, tournament.level, tournament.paused, tournament.resumed, tournament.heartbeat, player.eliminated, player.reentered, tournament.completed, tournament.cancelled; anything else is acknowledged and ignored.

Security

Game Night includes several security measures:

  • Password hashing — All passwords stored with bcrypt.
  • CSRF protection — Every form includes a CSRF token.
  • Prepared statements — All database queries use PDO prepared statements (no SQL injection).
  • HTML sanitization — Post content is sanitized to prevent XSS.
  • Security headers — CSP, X-Frame-Options, X-Content-Type-Options, and more.
  • Session security — HTTPOnly cookies, SameSite=Lax, strict session ids, session regeneration and a fresh CSRF token on login, and every session of an account ends when its password changes or is reset.
  • Account changes need the password — changing the username, email or phone, enabling or disabling two-factor, and deleting the account all confirm the current password; a new email is confirmed from a link sent to it before it takes effect; sign-in codes are never texted to an unverified number.
  • Last admin protection — The last admin account cannot be demoted or deleted.
  • File upload validation — MIME type checking on all uploads.
  • Sign-in bridge — Connected apps receive a 120-second, single-use, ES256-signed token carrying the username and the path to the member's profile photo; the private key never leaves the server and the token travels in a URL fragment. See Connected Apps: Sign-In Bridge.

Troubleshooting

HTTP 500 on fresh deploy

Check file permissions on the db/ directory. It must be writable by www-data:

chown -R www-data:www-data db/ www/uploads/

Emails not sending

  1. Verify Enable Notifications is on in Admin > General.
  2. Check your SMTP credentials in Admin > Email.
  3. Use Send Test Email to diagnose. Test emails bypass the notification toggle.
  4. Check the Activity Log for error messages.

SMS not sending

  1. Verify your SMS provider credentials in Admin > SMS.
  2. Use Send Test SMS to diagnose.
  3. Ensure phone numbers are in the correct format (the system normalizes to E.164).

Calendar not showing

Check Admin > General > Enable Calendar is toggled on.

Users can't create events

Enable Allow Users to Create Events in Admin > General.

Forgot admin password

If you've lost access to the admin account, you can reset the database:

# Warning: this deletes all data
rm db/gamenight.db
# Restart the app — it will recreate the database with default admin/admin
docker compose restart

Event times showing wrong

Check your timezone setting in Admin > General. All times are stored in UTC and converted for display using the configured timezone.