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 answer on its owner's behalf. There is no OAuth flow, no consent screen and nothing to provision.
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 60 days ahead with no account, 180 days
on a free account and 400 days on Pro. Share
https://whenwhen.io/e/{eventId} with participants and
keep the admin token to yourself.
A token creates on its 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. Slot counts and event lifetimes per tier
are on the pricing page.
Events you created before you had a token
Post the admin tokens of events you created without an account and they join your account. Each is re-measured on your tier, counted from when it was created rather than from now, so a 10-day event becomes a 30-day one on a free account or a year on Pro.
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. Lifetimes only ever extend: moving up a tier lengthens the events you already have, and cancelling a subscription shortens nothing.
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, answer each option, submit. The share link is the authorization; no other credential is needed.
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 answer. Come back later with
PUT /api/events/{id}/votes/{participantId} and the edit token to
change your mind.
Changing the times
Add or remove time options after the event is shared. Votes on the
remaining options are kept. Requires an account; an event created without
one returns 400 validation until you claim it.
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 reports how many were deleted.
Adding an option leaves no entry in the votes map for
participants who have already voted. A missing key means they have not
answered 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 the event is past
its lifetime. 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: 20 per IP per hour, and 60 per event per
calling network. Correct ones are refunded, so a whole invited group
opening the same event spends neither budget. The admin token bypasses all
of this, and GET /api/admin/{adminToken} returns the password
itself along with canSetPassword.
Finalizing
The organizer locks in 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 locked-in 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 the event is within its lifetime.
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.
The case this covers is 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. Token callers
have a namespace to themselves.
Rate limits
| Operation | Limit | Window |
|---|---|---|
| Create an event (no token) | 20 per IP | UTC day |
| Create an event (free token) | 20 per account | 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 | 100 / 250 / 1000 by tier | per event |
| Time slots per event | 10 / 25 / 100 by tier | per event |
| Participants x time slots | 20,000 | per event |
| Scheduling horizon | 60 / 180 / 400 days by tier | per time slot |
| Events running at once | 100 free / 1000 Pro | per 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 a guesser cannot lock the rest of the world out of an event by getting it wrong forty times.
The two read rows work differently from the rest. The per-IP one 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 and a link in a busy group chat does not throttle itself. 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 if you like and revalidate it against the ETag rather
than reusing it directly.
There is no cap on how many events you create in total. The "Events
running at once" 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. At 20 creates a day against a 30-day lifetime, an
agent reaches the free ceiling in about a week.
Participants multiplied by time slots is capped per event as well.
Below Pro the two per-tier caps never multiply past it. On Pro it means 100
time slots come with room for 200 people, and 1000 people come with room
for 20 time slots. A participant arriving at a full event gets
409 conflict, and adding slots that would put an event over
gets 400 validation; both messages name the number you have
room for.
Questions
If you are building against this API and something is missing or unclear, send it through the support form on your account page - you get a ticket id back, and we can see which plan and limits you are working against without asking. If you do not have an account yet, write to support@whenwhen.io instead.