OpenRally × The Padel Society · 2 Sep 2026 · build report

OpenRally Open API v1 — tournaments,
and “From OpenRally” inside TPS

Any third party can now list OpenRally tournaments, register players and receive live updates with one API key. TPS is the first consumer: a “From OpenRally” section in Competitions with naked portrait tiles, a detail screen, a three-step registration flow, and counts that move in real time. Built, security-reviewed twice, verified end to end on a local stack. Nothing is deployed.

Endpoints
18
reads, join, cancel, players, webhooks, SSE
Tests green
280+
API 54 unit + 60 smoke · data 20 · TPS backend 42 · Flutter 90
Security review
PASS
Max, 2 rounds · 3 criticals found and closed
Commits, 3 repos
36
on feature branches · not pushed · not deployed

The live demo

TPS app: Bangkok Open tile count moving from 21 to 22 teams while a registration lands via the API

14 s recording of the iPhone simulator. Full video: demo-live.mp4

What you are watching: the TPS app sits on the Play tab. From a terminal, a doubles team is registered into Bangkok Open through the public API with the TPS secret key. About a second later the tile reads 22 of 32 teams · 10 spots left where it read 21 and 11. Nobody touched the phone. The same path was proven from the OpenRally web app and from the TPS registration flow itself.

curl -X POST $API/tournaments/<uuid>/registrations \
  -H "X-OpenRally-Key: ork_live_…" \
  -H "X-OpenRally-Idempotency-Key: $(uuidgen)" \
  -d '{"type":"doubles","category_id":"…",
       "player":{"email":"carlos@…","display_name":"Carlos Demo"},
       "partner":{"email":"dan@…","display_name":"Dan Demo"}}'
# → 201 Registration … and on the stream, ~1 s later:
id: 412
event: registration_created
data: {"type":"registration_created","data":{"tournament":{"capacity":
      {"confirmed":22,"draw_size":32,"spots_left":10,…}},"registration":{…}}}

Everything else the app agent verified live against the real API: solo and doubles joins as Nestor’s own TPS identity, waitlist placement when a division fills, withdraw putting the spot back, the “You’re in” state surviving a restart, and missed events replayed over Last-Event-ID after the app was backgrounded.

What TPS looks like

Competitions → “From OpenRally”, naked Airbnb-style tiles (poster from the fixture; real posters below)
Real posters, live status line, “Full · 1 waiting” state
Detail: hero, chips, live capacity bar, sticky Register
Categories with spots left, entries list
Register step 1 · category
Step 2 · solo or doubles + partner picker (reused TPS field)
Step 3 · review
Confirmed
Withdraw, verified against the real API
Replay after backgrounding: two missed joins recovered
Waitlist section when a division is full
Developer docs site (apps/developers)

Seeded posters (generated, 1080×1350)

Architecture, decided

Where it lives

One Edge Function router in OpenRally’s Supabase: /functions/v1/openrally-api/v1/*. OpenRally owns its API; no dependency on the Setpoint Rust protocol server or Fly.

Keys

ork_live_ secret keys for servers (all scopes) and ork_pub_ publishable keys safe to ship in an app (read + stream, per-client rate buckets via X-Client-Id). Only sha256 is stored.

Identity

Partners send email + display name. Existing OpenRally users are matched; otherwise a claimable placeholder is created, the same rule organisers already use. Players are or://openrally/player/{id}.

Events from the database

Triggers on tournament, registration, category, venue, draw, match and placement write protocol_event. A change made in OpenRally web fires exactly like one made through the API. Every event carries the full tournament snapshot with capacity.

Push

Signed webhooks (X-OpenRally-Signature: v1=HMAC(ts.body), claim-based worker, backoff, SSRF guard) for servers, and an SSE stream with Last-Event-ID replay for clients.

Capacity semantics

Counts are in teams: confirmed = doubles + floor(solo/2), plus solo_players so the UI can say “13 solo players looking for a partner”. Doubles are two rows in OpenRally; is_primary marks the canonical one and lists return one entry per team.

Security review (Max, Anthropic top rung)

Two adversarial passes with live probes. Round one: 3 critical8 warning6 info. Round two closed everything, then found an IPv6-mapped SSRF bypass and an uncommitted fix, both closed. Final verdict PASS.

FindingWhat it wasFix
C1Any TPS user could cancel another user’s registration by planting a receipt rowCancel requires player.email that is a member of the team; link ownership alone is never enough; TPS RLS refuses foreign registration ids
C2Deterministic idempotency key replayed a stale “confirmed” after withdraw; business 409s cached 7 daysFresh UUID per attempt on TPS; API caches only 2xx and validation 4xx; 60 s in-flight takeover bounded by created_at
C3One rate-limit bucket for every app installPer-client buckets (X-Client-Id / CF-Connecting-IP / last XFF hop) under a 2000/min key ceiling
W4Webhook SSRF: private ranges, redirects, body echo; then IPv4-mapped IPv6 literalsResolve + blocklist incl. mapped/NAT64/6to4, redirect: error, per-delivery recheck, no body echo
W5–W10, I11–I17Worker lock that never held, out-of-order patches, unpublish/delete never emitted, unbounded inputs, cursor injection, error leakage, withdrawn history readable by app keys…Claim RPC with SKIP LOCKED, per-row event guards, new tournament_unpublished/deleted events, caps, validated cursors, request ids, forbidden_status for publishable keys

What was built, by stream

StreamWhereHighlights
Contractopenrally/apps/product-hub/Plans/openrally-open-api-v1.mdNine decisions, objects, endpoints, DB, TPS integration, demo script, deploy dependencies
Data layerapps/backend/supabase/migrations/20260902100000_open_api_v1.sql, scripts/{mint,revoke}-api-key.ts, scripts/seed-open-api-demo.ts7 tables, SQL serializer, emitter, 7 trigger sets, claim + rate-limit RPCs, generated posters, 20 tests
APIapps/backend/supabase/functions/openrally-api, …/openrally-api-webhook-worker, openapi/openrally-api.v1.yaml, docs/OPEN-API.md18 routes, auth/scope/rate/idempotency middleware, SSE, signed worker, OpenAPI, smoke script + echo receiver
Developer siteapps/developers/ (static, zeroship-ready)Quickstart, reference, webhooks + signature samples in TS/Py/Go, SSE guide, TPS embed guide, live stream demo box
TPS backendpadel-backend-wt-openrally: migration + openrally-tournaments-{webhook,sync,join,cancel}Signed webhook receiver → mirror + Realtime, prune-safe sync, join/cancel proxies holding the secret key, 42 tests
TPS appmobile-app-padel/lib/features/openrally/ behind flag tps-0-openrally-tournamentsAPI client + SSE parser with reconnect, section, detail, 3-step registration reusing the partner picker, live patching, 90 tests

Your dependencies (deploy only — nothing blocks the build)

  1. OpenRally prod (hqcwmjninvunoexccrbz, not in the Supabase MCP org): apply 20260902100000_open_api_v1.sql, deploy openrally-api and openrally-api-webhook-worker, set API_WEBHOOK_WORKER_SECRET, the two GUCs and the worker URL, schedule the cron, mint the TPS secret + publishable keys (activation SQL in docs/OPEN-API.md).
  2. TPS prod (tnyqbaqnugjgefkdvmag): apply the mirror migration, deploy the four functions, set OPENRALLY_API_URL/KEY/WEBHOOK_SECRET, create the webhook subscription (curl in docs/openrally-tournaments.md).
  3. TPS app: put the publishable key in the release env, ship (Shorebird patch or store build), flip the PostHog flag.
  4. Optional: api.openrally.co in front of the function URL; publish apps/developers with zeroship.

Local demo stays live on this machine: OpenRally stack on ports 563xx, API served, join/cancel proxies on 8801/8792, OpenRally web on :3005, app on the iPhone 16e simulator. Recipe in memory and in docs/OPEN-API.md.