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.
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 can | A key can never |
|---|---|
| Read the club page | Appoint or remove officers |
| Read the roster | Mint, read or revoke other keys |
| Add and remove plain members | Create or revoke invite codes |
| Post announcements | Hand over or take the presidency |
| Read the daily standings | Touch 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 path | What it does |
|---|---|
GET /api/v1/openapi.json | This API, described. No key required. |
GET /api/v1/clubs/{slug} | The club's page data. |
GET /api/v1/clubs/{slug}/members | The roster, officers first. |
POST /api/v1/clubs/{slug}/members | Add a member by name. |
DELETE /api/v1/clubs/{slug}/members/{id} | Remove a member. Officers are refused. |
POST /api/v1/clubs/{slug}/announcements | Publish an announcement. |
GET /api/v1/clubs/{slug}/daily | The 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" }
| Status | Means |
|---|---|
401 | Missing, malformed or revoked key. |
404 | No such club, or not the club this key belongs to. |
429 | Rate 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#
| Event | Fires when |
|---|---|
member.joined | Somebody joins the roster through an invite link. |
announcement.published | An announcement is published. |
challenge.completed | A 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:
X-Curltime-Event— the event name, so you can route without parsing.X-Curltime-Signature— the hex HMAC-SHA256 of the raw request body under the hook'swhsec_...secret, which is shown once at creation.
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#
- Success is any 2xx within ten seconds.
- A failure retries at +1 and +5 minutes, then gives up after three attempts.
- Eight consecutive give-ups disable the hook. Editing it re-enables it.
- The management screen shows each hook's recent deliveries and a Test button that sends a
testevent and reports what happened.
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:
GET /api/clubs— the public club directory.GET /api/clubs/{slug}/events.ics— the club's events as an RFC 5545 calendar feed, for subscribing rather than for scraping./llms.txt— a plain-text summary of the site and the facts most often got wrong about it./sitemap.xml— every indexable page.
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.