Developers & agents

Everything you can do on this site you can do over plain JSON. Taking part in someone else's event needs no account and no key.

How authorization works

Capability URLs are the credential. There are no API keys on the participant side:

Give an agent a share link and it can vote on its owner's behalf.

Tokens are shown once. The admin token and edit tokens are returned in the response body of the request that creates them and are never recoverable afterwards. Store them the moment you receive them, and treat them like passwords: anyone holding an admin token is the organizer.

Creating an event

Creating an event needs a credential, to prevent abuse. Voting, viewing and finalizing do not. Two ways to pass:

Turnstile rejects non-browsers, so agents use a token instead. Mint one from your account page.

curl -sS https://whenwhen.io/api/events \
  -H 'Authorization: Bearer ww_...' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 6f1c9a2e-b4d5-4a71-9c33-8e2f7a10b5cd' \
  -d '{
    "title": "Design review",
    "durationMin": 60,
    "tz": "America/Chicago",
    "options": [1785312000, 1785398400, 1785484800]
  }'

{"eventId":"k3Ln8pQx2vRa","adminToken":"...","expiresAt":1786176000}

options are proposed start times in epoch seconds. Duplicates are collapsed server-side; each must fall between 24 hours ago and the caller's scheduling horizon, which is 90 days ahead with no account and 400 days on Pro. Share https://whenwhen.io/e/{eventId} with participants and keep the admin token to yourself.

A token belongs to a Pro account and creates on that account's tier, exactly as the browser would. Without a token, send turnstileToken in the body instead and the event lands on the no-account tier; that create may also carry an optional email, the organizer's own address, which is mailed the share link and the admin link once and then discarded. Slot counts and scheduling windows per tier are on the pricing page.

expiresAt is when voting closes: the latest options start you sent, exactly. There is no per-tier lifetime and no floor. The tier's scheduling window bounds it, since a slot cannot be proposed beyond that window. A Pro organizer can close voting earlier with voteDeadline: the key appears in the event body only when set, absence means voting closes at expiresAt, and from that second both vote endpoints answer 409 closed while the event stays open for finalizing.

A create whose times have all already passed is refused with 400 validation - it would be born expired, and PATCH is refused past expiresAt, so there would be no way to edit it back. A single past slot is fine as long as one is still ahead.

Events you created before you had a token

Post the admin tokens of events you created without an account and they join your account and move onto its tier, which widens their slot cap, participant cap and scheduling window for later edits. The account has to be Pro: it is the plan that owns events.

curl -sS https://whenwhen.io/api/events/claim \
  -H 'Authorization: Bearer ww_...' \
  -H 'Content-Type: application/json' \
  -d '{"adminTokens": ["...", "..."]}'

{"claimed":2}

Expired, unknown and already-owned tokens are skipped rather than refused, so re-sending the same list is safe. Claiming does not move expiresAt: that follows the event's own time slots.

API tokens are shown once and hashed at rest. Only a SHA-256 is stored, so a lost token is replaced rather than recovered. Revoke from the same page; revocation takes effect on the next request. A token cannot mint or revoke tokens, and cannot delete its own account. Those need a browser session.

Voting

Read the event, vote on each option, submit. The share link is the authorization.

curl -sS https://whenwhen.io/api/events/k3Ln8pQx2vRa

curl -sS https://whenwhen.io/api/events/k3Ln8pQx2vRa/votes \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Ada",
    "votes": { "<optionId>": 2, "<optionId>": 1, "<optionId>": 0 }
  }'

{"participantId":"...","editToken":"..."}

Vote values are 2 = Yes, 1 = If Needed, 0 = No. Options you leave out default to No, unless the event was created with requireVotes, in which case every option must carry an explicit vote. Come back later with PUT /api/events/{id}/votes/{participantId} and the edit token to change your mind.

Some events restrict who sees the votes (event.voteVisibility, present only when set). There the public read returns an empty participants list, with participantCount and, for totals, per-option tallies standing in. Your own submission is still yours: POST /api/events/{id}/my-votes with {"participantId": "...", "editToken": "..."} returns your name and votes, which is how a client prefills an edit instead of blindly replacing stored votes.

Changing the times

Add or remove time options after the event is shared. Votes on the remaining options are kept. The admin token is the only credential; no account or plan is needed.

curl -sS -X PATCH https://whenwhen.io/api/admin/<adminToken> \
  -H 'Content-Type: application/json' \
  -d '{"addOptions": [1786000000], "removeOptionIds": ["<optionId>"]}'

{"ok":true,"added":1,"removed":1,"votesRemoved":4,"optionCount":3}

Removing an option deletes the votes cast on it; votesRemoved counts them.

Adding an option leaves no entry in the votes map for participants who have already voted. A missing key means they have not voted on that option. Do not read it as 0.

Editing the times returns 409 closed on a finalized event; call reopen first. It is also refused once voting has closed on the event. GET /api/admin/{adminToken} returns canEditOptions.

Password-protected events

A Pro organizer can put a password in front of an event. Reading it, downloading its calendar file and voting on it all answer 403 with "code": "locked" until you prove you know the password once.

curl -sS https://whenwhen.io/api/events/k3Ln8pQx2vRa
{"error":"This event is password protected - POST the password to ...","code":"locked"}

curl -sS https://whenwhen.io/api/events/k3Ln8pQx2vRa/unlock \
  -H 'Content-Type: application/json' \
  -d '{"password": "harbour-lantern"}'

{"viewToken":"..."}

curl -sS https://whenwhen.io/api/events/k3Ln8pQx2vRa \
  -H 'X-View-Token: <viewToken>'

Hold the viewToken and send it as X-View-Token from then on. The password crosses the wire once, so an agent that stores the token is not storing the password. The token stays valid until the organizer changes the password, which is how access is withdrawn.

Holding the share link and no password gets you the 403 and nothing else: no title, no times, no participants. The calendar download also takes the token as a ?viewToken= query parameter, because a browser following a download link cannot set a header. Everything else should use the header, which stays out of request logs.

Wrong passwords are budgeted per IP and per event; the Rate limits table below carries the numbers. Correct ones are refunded. The admin token bypasses all of this, and GET /api/admin/{adminToken} returns the password itself along with canSetPassword.

Finalizing

The organizer schedules the winner with the admin token. Finalizing and the calendar file are included on every tier.

curl -sS https://whenwhen.io/api/admin/<adminToken>/finalize \
  -H 'Content-Type: application/json' \
  -d '{"optionIds": ["<optionId>"]}'

curl -sS https://whenwhen.io/api/events/k3Ln8pQx2vRa/calendar.ics

optionIds accepts more than one id, to schedule several slots at once Pro. The plan is checked when you call finalize, so upgrading enables this on events you already have.

The .ics carries one VEVENT per scheduled slot. UIDs are stable per slot, so re-downloading after a change updates the imported entry rather than adding another. POST .../reopen undoes a finalize while voting is still open.

Conventions

Idempotency

POST /api/events accepts an optional Idempotency-Key header of up to 200 printable ASCII characters. Retry with the same key and the same body inside 24 hours and you get the original 201 back - same event id, same admin token - marked Idempotency-Replayed: true, instead of a second event.

This covers a create that succeeded with a response you never received. A blind retry fails outright, because Turnstile tokens are single-use.

Reusing a key with a different body is 409 conflict, never someone else's event. Keys are scoped to the caller: your API token if you sent one, otherwise your IP address. Anonymous callers behind one office or CGNAT address therefore share a key namespace, so use an unguessable key (a UUID), not retry-1.

Rate limits

OperationLimitWindow
Create an event (no token)20 per IPUTC day
Create an event (Pro token)250 per accountUTC day
Submit or edit votes120 per IPUTC hour
Wrong password on a protected event20 per IPUTC hour
Wrong password on a protected event60 per event per networkUTC hour
Read an event or the organizer view60 per IP60 seconds
Rebuild the response for one event300 per event60 seconds
Participants per event250 / 1000 by tierper event
Time slots per event25 / 100 by tierper event
Scheduling horizon90 / 400 days by tierper time slot
Active events1000 Proper Pro account

A token meters against its account's budget instead of the per-IP one, so a fleet of agents behind one egress address is not throttled at the anonymous rate. The budget belongs to the account, so several tokens share it and revoking one does not reset it.

The two password rows count wrong guesses. A correct password refunds both budgets, so a whole invited group opening one protected event spends nothing. The per-event row is keyed on the calling network as well as the event, so one guesser cannot lock everyone else out of an event.

The per-IP read budget is charged before anything is looked up. The per-event one is charged only when the response has to be rebuilt, so a conditional read or a cached one never spends it. Both are counted per Cloudflare data centre, which bounds how fast one event can be read from one place rather than setting a global quota.

Polling without being throttled

GET /api/events/{id} and GET /api/admin/{adminToken} return a weak ETag. Send it back as If-None-Match and an unchanged event answers 304 with no body, at the cost of one row read whatever the size of the event. Build your polling loop on this.

The tag changes whenever anything in the response changes, including two things that are not edits to the event: the organizer changing plan, which moves maxApprovals, and the event passing expiresAt, which flips status to expired. A 304 therefore means the whole payload is still current.

Responses carry Cache-Control: private, no-cache, so keep a copy and revalidate it against the ETag rather than reusing it directly.

There is no cap on how many events you create in total. The "Active events" row above is the ceiling on how many may be open at the same time, and it is higher on Pro Pro. Creating past it returns 409 conflict until an older event expires or you delete it. A Pro token meters at 250 creates a day, so the daily budget runs out long before the ceiling does.

Questions

If something here is missing or unclear, send it through the support form on your account page - you get a ticket id back, and the ticket carries your plan and limits, so we do not have to ask. Without an account, write to support@whenwhen.io.