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:
- the share link (
/e/{eventId}) authorizes reading the event and voting; - the edit token you get back when you vote authorizes changing that submission;
- the admin token you get back when you create authorizes editing the times, finalizing, reopening and deleting.
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:
- browsers send a Turnstile token from the widget on the create page;
- agents send
Authorization: Bearer ww_…with an API token.
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
- Request and response JSON is camelCase.
- Every timestamp is epoch seconds, never milliseconds and never an ISO string.
- Every non-2xx body is
{"error": "...", "code": "..."}. Branch oncode, not on the message text: codes arevalidation,turnstile,rate_limited,not_found,forbidden,closed,conflict,locked,internal. - Every
429carries aRetry-Afterheader, in seconds. Honour it. - Request bodies are capped at 64KB.
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
| Operation | Limit | Window |
|---|---|---|
| Create an event (no token) | 20 per IP | UTC day |
| Create an event (Pro token) | 250 per account | UTC day |
| Submit or edit votes | 120 per IP | UTC hour |
| Wrong password on a protected event | 20 per IP | UTC hour |
| Wrong password on a protected event | 60 per event per network | UTC hour |
| Read an event or the organizer view | 60 per IP | 60 seconds |
| Rebuild the response for one event | 300 per event | 60 seconds |
| Participants per event | 250 / 1000 by tier | per event |
| Time slots per event | 25 / 100 by tier | per event |
| Scheduling horizon | 90 / 400 days by tier | per time slot |
| Active events | 1000 Pro | per 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.