â„šī¸ Authentication
Admin endpoints require a valid JWT in the Authorization: Bearer <token> header, or in the token cookie set by a successful login. Public endpoints require no auth.

Public Pages (HTML)

Server-rendered HTML pages returned to browsers.

GET /
Home page. Lists active waffles with live spot counts.
GET /waffles
Full public waffle list.
GET /waffle/:slug
Waffle detail page with live spot grid. This is the URL you share with buyers.
GET /buyer/:handle
Public buyer stats page showing win/loss history, luck rating, and trophy case for an Instagram handle.
GET /buyer/:handle/card
Added in v0.1.23-beta.0. Chromeless, shareable buyer stat card — optimized for screenshot and social sharing. Shows wins, losses, spots claimed, win rate, luck rating (actual vs. statistically expected win rate), and trophy case. Includes Open Graph / Twitter Card meta tags so the URL previews inline when posted to Instagram or other platforms.
GET /waffle/:slug/card.png
Added in v0.1.23-beta.3. Downloadable PNG share card for a waffle. Rate-limited. Accepts a ?format= query parameter:
  • story (default) — 1080×1920 vertical, ideal for Instagram Stories
  • square — 1080×1080, ideal for feed posts
Card shows the waffle title, spot price, spots remaining, and the public claim URL. Generated with Inter font and Project Syrup branding; cached on disk per slug+format (invalidated by the regenerate endpoint). Returns Content-Type: image/png with Cache-Control: public, max-age=3600. Returns 404 for archived waffles. No auth required.
GET /about
Public about page. Authenticated admins see additional system information.

Public API — Waffles

GET /api/waffles
List all active (non-archived) waffles. Returns an array of waffle summary objects.
GET /api/waffles/:slug
Get full waffle details by slug, including title, price, total spots, claimed/paid counts, and media links.
GET /api/waffles/:slug/export
Download waffle spot list as CSV. Columns: spot number, status, claimed_by_handle. No auth required.

Public API — Spots & Claims

GET /api/waffles/:slug/spots
Get the full spot grid for a waffle. Returns all spots with number, status, and (if claimed) the Instagram handle.
POST /api/claims
Claim one or more specific spots. Rate-limited.
{
  "waffle_id": "<uuid>",
  "spots": [4, 7, 12],
  "instagram_handle": "dani_boo_glass"
}
Returns the updated spot objects or an error if any spot is already taken. Transactionally safe — no double-claims possible. Instagram handle is normalized server-side (lowercased, @ stripped).
POST /api/claims/random
Added in v0.1.21-beta.0. Claim count random available spots in a single transaction. Rate-limited via the same limiter as /api/claims.
{
  "waffle_id": "<uuid>",
  "count": 5,
  "instagram_handle": "dani_boo_glass"
}

Server selects count random spots under a row-level lock and claims them. Returns:

{
  "claimed_count": 5,
  "requested_count": 5,
  "spot_numbers": [3, 8, 14, 22, 29],
  "message": "Claimed 5 spots"
}

Partial fulfillment: if fewer spots are available than requested, the server claims as many as it can and returns claimed_count < requested_count with a message like "Claimed 3 of 5 requested spots". No client-side maximum cap is enforced — the server is authoritative.

Each claimed spot fires a separate WebSocket spot_update broadcast.

Public API — Buyers

GET /api/buyers/:handle/stats
Win/loss statistics for an Instagram handle. Returns total claims, wins, and losses.
GET /api/buyers/:handle/history
Full claim history for an Instagram handle across all waffles.
GET /api/buyers/:handle/card
Added in v0.1.23-beta.0. Returns all computed buyer card data as JSON. Always returns 200 — unknown handles return zeroed stats rather than 404.
{
  "instagram_handle": "dani_boo_glass",
  "stats": { "total_wins": 3, "total_losses": 12, "total_spots_claimed": 47, ... },
  "win_rate": 0.0638,
  "expected_win_rate": 0.0421,
  "luck_rating": 0.0217,
  "trophies": ["Stanley Cup", "Mystery Box"],
  "waffle_history": [...]
}

luck_rating = actual win rate minus statistically expected win rate over completed waffles. Positive = luckier than expected; negative = unluckier.

trophies are item names extracted from double-quoted text in waffle titles (e.g. a title of Waffle — "Stanley Cup" yields "Stanley Cup").

Public API — Misc

GET /api/version
Added in v0.1.23-beta.2. Returns the running app version. No authentication required.
{ "version": "v0.1.23-beta.3" }

Admin API — Auth

POST /api/admin/login
Authenticate and receive a JWT.
{ "username": "admin", "password": "syrup" }
Returns { "token": "..." } on success. Sets a secure token cookie. Returns 401 on bad credentials; 429 after lockout threshold is exceeded.
POST /api/admin/logout
Clears the session cookie. No body required.
POST /api/admin/forgot-password
Request a password reset token by username.
{ "username": "admin" }
Always returns 200 (does not reveal whether the username exists). A super_admin must retrieve the token and share it with the user out-of-band.
POST /api/admin/reset-password
Reset password using a token.
{ "token": "...", "new_password": "..." }
Token is single-use and expires. Password must meet the password policy.

Admin API — Current Admin

GET /api/admin/me
Returns the authenticated admin's profile: id, username, role, timezone, profile fields.
PATCH /api/admin/me/timezone
Update timezone preference.
{ "timezone": "America/New_York" }
POST /api/admin/change-password
Change your own password. Requires current password.
{ "current_password": "...", "new_password": "..." }

Admin API — Waffles

GET /api/admin/waffles
List waffles. Query param: ?archived=true for archived waffles, ?archived=false (default) for active.
POST /api/admin/waffles
Create a new waffle.
{
  "title": "Blue Wig #3",
  "description": "Limited drop",
  "total_spots": 50,
  "spot_price": 5,
  "payment_methods": ["<payment-method-uuid>", "<payment-method-uuid>"],
  "item_count": 1,
  "image_url": "https://...",
  "instagram_media_links": ["https://www.instagram.com/p/..."]
}

item_count defaults to 1 if omitted. Set to 2–10 for multi-item waffles where you'll draw that many winners. Returns the created waffle with its generated slug.

PATCH /api/admin/waffles/:id
Update waffle title, price, or media links. Spot count cannot be changed after creation.
POST /api/admin/waffles/:id/archive
Archive a waffle. Requires admin or higher.
POST /api/admin/waffles/:id/unarchive
Restore an archived waffle to active status.
DELETE /api/admin/waffles/:id
Permanently delete a waffle and all its spots. Requires typing DELETE and providing current password as confirmation.
{ "confirmation": "DELETE", "password": "..." }
Requires admin or higher. Irreversible.
POST /api/admin/waffles/:id/winner
Set winner(s). Updated in v0.1.20 to accept an array — supply one entry per item.
{ "winning_spot_numbers": [17] }
// or for a multi-item waffle (item_count > 1):
{ "winning_spot_numbers": [17, 23, 42] }
Marks each listed spot as winner, marks all other paid spots as loser, populates the waffle's winning_instagram_handles array, and updates buyer stats. Spots must be distinct and currently paid. Fires a waffle_completed WebSocket broadcast.
POST /api/admin/waffles/:id/clear-winner
Clear all winners on the waffle. Resets every winning spot to paid, restores loser spots to paid, recalculates buyer stats, and reverts the waffle to active. Fires a winner_cleared WebSocket broadcast.
POST /api/admin/waffles/:id/change-winner
Reassign winner(s) without clearing first. Same payload shape as /winner.
{ "winning_spot_numbers": [23] }
// or for multi-item:
{ "winning_spot_numbers": [23, 31, 44] }
Old winning spots not in the new selection revert to paid; new selections become winner; loser status is recomputed across all paid spots. Recalculates buyer stats. Fires a winner_changed WebSocket broadcast.
GET /api/admin/waffles/:id/share-message
Added in v0.1.23-beta.3. Get the current share message and template selection for a waffle.
PATCH /api/admin/waffles/:id/share-message
Added in v0.1.23-beta.3. Save a share message for a waffle. Both fields required together.
{ "template_id": "<uuid>", "message": "Grab your spots! 🧇\n{url}" }
POST /api/admin/waffles/:id/share-message/render
Added in v0.1.23-beta.3. Render a template body with waffle data substituted. Returns the expanded message string for preview.
POST /api/admin/waffles/:id/share-message/regenerate-card
Added in v0.1.23-beta.3. Invalidate the cached PNG for this waffle (both story and square formats). The next request to /waffle/:slug/card.png will regenerate it.

Admin API — Share Templates v0.1.23-beta

Share message templates are reusable caption bodies. Requires waffle_manager or higher. Supports placeholders: {title}, {price}, {spots}, {url}.

GET /api/admin/share-templates/
List all message templates. Returns {"templates": [...]}.
POST /api/admin/share-templates/
Create a new template.
{ "name": "Standard Drop", "body": "Spots open! 🧇\nLink: {url}" }
PATCH /api/admin/share-templates/:id
Update a template's name or body. Same payload shape as POST.
DELETE /api/admin/share-templates/:id
Delete a template.
POST /api/admin/share-templates/:id/default
Set this template as the default. The default is pre-selected in the share message editor on the waffle manage page.

Admin API — Spots

POST /api/admin/spots/:id/pay
Mark a pending spot as paid. Broadcasts a WebSocket update to all connected clients.
POST /api/admin/spots/:id/release
Release a pending or paid spot back to available. Broadcasts a WebSocket update.

Admin API — Payment Methods v0.1.22

Stored payment method pool (Venmo, PayPal, CashApp, Zelle). Admin and super_admin can create, list, update, and soft-delete entries. Waffles reference payment methods by UUID via the payment_methods array on create/edit.

GET /api/admin/payment-methods/
List all payment methods (active and inactive). Returns an array of PaymentMethod objects with id, type, display_name, handle_or_url, and is_active.
POST /api/admin/payment-methods/
Create a new payment method.
{
  "type": "venmo",
  "display_name": "Venmo — @notfixingit",
  "handle_or_url": "@notfixingit"
}
Valid type values: venmo, paypal, cashapp, zelle. handle_or_url can be a raw handle (the public page will format the link based on type) or a full URL. New methods are is_active: true by default.
PATCH /api/admin/payment-methods/:id
Update a payment method's display name, handle, or active status.
{ "display_name": "Venmo — Primary", "handle_or_url": "@notfixingit", "is_active": true }
All fields are optional — only the ones present are updated. Changes propagate to every waffle that references this method.
DELETE /api/admin/payment-methods/:id
Soft-delete a payment method (sets is_active = false). The record is preserved so historical waffles still resolve. The method no longer appears in the multi-select on new or edited waffles. To reactivate, PATCH with {"is_active": true}.

Admin API — Admin Management

All endpoints in this section require super_admin role.

GET /api/admin/admins
List all admin accounts with username, role, and active status.
POST /api/admin/admins
Create a new admin account.
{ "username": "jane", "password": "...", "role": "admin" }
Valid roles: super_admin, admin, waffle_manager.
PATCH /api/admin/admins/:id
Update role. Demotions require current password confirmation.
{ "role": "waffle_manager", "current_password": "..." }
PATCH /api/admin/admins/:id/password
Reset another admin's password. The admin should change it on next login.
{ "new_password": "..." }
DELETE /api/admin/admins/:id
Deactivate an admin account. Requires current password.
{ "current_password": "..." }
Deactivated admins cannot log in. Their audit history is preserved. They can be reactivated.

Admin API — Reports

Available to all roles.

GET /api/admin/reports/drought
Buyers with the most losses and no recent win, sorted by loss streak.
GET /api/admin/reports/power-buyers
Buyers ranked by total paid spots across all waffles.
GET /api/admin/reports/monthly-activity
Waffles run, spots claimed, and payments received grouped by calendar month.
GET /api/admin/reports/spot-velocity
Time from waffle creation to last spot claimed, per waffle. Shows how quickly your drops sell out.

Admin API — Audit Log

Requires admin or super_admin.

GET /api/admin/audit
List audit log entries with pagination. Query params: ?page=1&per_page=50&admin_id=&action=&from=&to=
GET /api/admin/audit/:id
Get a single audit log entry by ID.
GET /api/admin/audit/export
Export filtered audit log entries as CSV. Same filter params as the list endpoint.

Admin API — Login History

GET /api/admin/login-history
List login history. Visibility is role-scoped:
  • waffle_manager — own entries only
  • admin — own + waffle_manager entries
  • super_admin — all entries
Optional query param: ?admin_id= (super_admin only).

Admin API — Users

GET /api/admin/users
List all registered buyer handles (the users registry). Returns handle and first-seen timestamp.

Admin API — Settings

Requires super_admin.

GET /api/admin/settings
Retrieve current system settings (WHOIS server, JWT expiry, retention periods, lockout config).
PATCH /api/admin/settings
Update one or more system settings.
{
  "whois_server": "whois.pwhois.org",
  "jwt_expiry_hours": 24,
  "audit_log_retention_days": 90,
  "login_history_retention_days": 90
}

WebSocket

WS /ws/:slug
Connect to the real-time hub for a waffle. Replace :slug with the waffle slug. The client receives JSON messages whenever a spot changes state.

Message format

{
  "type": "spot_update",
  "spot": {
    "id": 42,
    "number": 7,
    "status": "paid",
    "claimed_by_handle": "dani_boo_glass"
  }
}

Message types

typeTrigger
spot_updateAny spot status change (claim, pay, release, winner)
winnerWinner entered — includes winning spot and handle
pingServer heartbeat (every ~30s) — client should reply pong

Reconnection

The client-side WebSocket implementation (websocket-client.js) uses exponential backoff with jitter and a maximum retry cap. Stale connections are detected via the server-side ping/pong heartbeat.

Health & Readiness

GET /health
Returns 200 with {"status":"ok","db":"connected"} when healthy. Returns 503 if the database is unreachable.
GET /ready
Readiness probe for container orchestrators. Same response format as /health.