CurlTi.me

CurlTi.me/Learn/The club API

The club API

A small, deliberately limited API for a club's own software — a website, a spreadsheet, a bot. Read the club, the roster and the standings; add members; post announcements. It can never touch officers, keys or the presidency.

Last updated 2026-08-16

What this is for#

A club that already has software — a website that lists members, a spreadsheet somebody maintains, a bot in a chat room — should not have to keep two things in step by hand. The v1 API exists so the club's own systems can read from and write to its CurlTi.me page directly.

It is small on purpose. Seven routes, one authentication scheme, one error shape.

Start here

The whole surface is described by /api/v1/openapi.json, an OpenAPI 3.1 document served without a key — documentation you need a key to read is documentation nobody integrating for the first time can read.

Getting a key#

The club president mints keys, in the API tab of the club's management screen. Vice-presidents cannot; this is one of the four things the presidency reserves.

The secret is shown exactly once. The server keeps only its SHA-256, the way it would a password, so there is no route by which anybody — including us — can show it to you again. Copy it at that moment or mint another one.

Authorization: Bearer ck_...

What a key can and cannot do#

A key acts on its own club only, and carries at most the content powers a vice-president has.

A key canA key can never
Read the club pageAppoint or remove officers
Read the rosterMint, read or revoke other keys
Add and remove plain membersCreate or revoke invite codes
Post announcementsHand over or take the presidency
Read the daily standingsTouch any other club

The shape of that table is the security model. A leaked key can vandalise things the president can repair — a bad announcement, a removed member — and it can never take the club or lock anyone out. Revoking it is one click.

The endpoints#

Method and pathWhat it does
GET /api/v1/openapi.jsonThis API, described. No key required.
GET /api/v1/clubs/{slug}The club's page data.
GET /api/v1/clubs/{slug}/membersThe roster, officers first.
POST /api/v1/clubs/{slug}/membersAdd a member by name.
DELETE /api/v1/clubs/{slug}/members/{id}Remove a member. Officers are refused.
POST /api/v1/clubs/{slug}/announcementsPublish an announcement.
GET /api/v1/clubs/{slug}/dailyThe club's board for today.
GET /api/v1/clubs/{slug}/daily/{day}The same for a given day, as YYYY-MM-DD.

The {slug} in the path must be the club the key belongs to. Asking about somebody else's club with your key is a 404, not a 403 — a key cannot be used to discover which clubs exist.

A first request#

curl -H "Authorization: Bearer ck_..." \
  https://curlti.me/api/v1/clubs/your-club/members
curl -X POST https://curlti.me/api/v1/clubs/your-club/announcements \
  -H "Authorization: Bearer ck_..." \
  -H "Content-Type: application/json" \
  -d '{"title":"Tuesday league draw","body":"Sheets 1 and 3, 7pm."}'

Rate limits and errors#

Sixty requests per minute per key. Over that you get 429 with a Retry-After header saying how long to wait. Announcements carry a second, tighter limit per club, because an announcement is something members are emailed about.

Every error uses the same envelope, whatever went wrong:

{ "error": "rate limit exceeded: 60 requests per minute per key" }
StatusMeans
401Missing, malformed or revoked key.
404No such club, or not the club this key belongs to.
429Rate limited. Read Retry-After.

Webhooks: the same conversation, outbound#

Where the API lets your software ask, webhooks let the club tell it. The president registers hooks beside the keys, in the same API tab. They ride the session rather than the keyed surface, deliberately — registering a hook is administration, not content.

The three events#

EventFires when
member.joinedSomebody joins the roster through an invite link.
announcement.publishedAn announcement is published.
challenge.completedA linked member sets a new best for a day — never on a retry that does not improve.

The envelope#

Every delivery is JSON in one shape:

{
  "event": "announcement.published",
  "at": "2026-08-16T19:04:11.000Z",
  "club": { "slug": "your-club", "name": "Your Club" },
  "data": { ... }
}

and carries two headers:

Verify the signature. It is the whole authenticity story.

Recompute the HMAC over the raw bytes you received — not over a re-serialised object, which will not match — and compare. Drop anything that does not. There is no other check that tells you a delivery came from us.

import { createHmac, timingSafeEqual } from 'node:crypto'

function verify(rawBody, header, secret) {
  const expected = createHmac('sha256', secret).update(rawBody).digest('hex')
  const a = Buffer.from(expected, 'utf8')
  const b = Buffer.from(header ?? '', 'utf8')
  return a.length === b.length && timingSafeEqual(a, b)
}

Delivery behaviour#

A webhook can never delay or fail the join, announcement or attempt that fired it. That is the same rule the analytics live under: nothing about telling somebody what happened may stop the thing from happening.

Things you may want that are not in v1#

A few useful URLs live outside the keyed API because they are public by nature:

Match pages at /m/<id> are not part of any API and are not listed anywhere. The ids are unguessable, and a match means nothing to anybody who is not playing it.

What "v1" promises#

The version is in the path so that a change which would break a client gets a new path rather than a quiet redefinition. Fields may be added to responses without notice, so parse leniently and ignore what you do not recognise.

The OpenAPI document is hand-written rather than generated, which is a deliberate choice: it changes when the routes change, in the same commit, and the difference between the two is something a person reviews.