{"openapi":"3.1.0","info":{"title":"WhenWhen API","version":"1.0.0","summary":"Group scheduling that agents can operate end to end.","description":"WhenWhen is a scheduling poll: an organizer proposes time slots, participants answer Yes / If needed / No through one shared link, and the organizer locks in the winner. Anyone with the link can then add it to their calendar.\n\n## How authorization works\n\nThere are no API keys on the participant side, and that is the point. Capability URLs are the credential:\n\n- the **share link** (`/e/{eventId}`) lets anyone read the event and vote,\n- the **edit token** returned when you vote lets you change that submission,\n- the **admin token** returned when you create lets you finalize, reopen and delete.\n\nAn agent handed a share link can participate immediately - no account, no OAuth, no consent screen. Treat edit and admin tokens as secrets: they are shown once and cannot be recovered.\n\nCreating an event is the one credentialed operation, because a scheduling link on a trusted domain is a phishing vector worth gating. Browsers pass a Turnstile token; agents send `Authorization: Bearer ww_…` with a token minted from an account page. Your agent needs credentials; your invitees never do.\n\n## Conventions\n\n- Request and response JSON is camelCase.\n- Every timestamp is **epoch seconds**, never milliseconds.\n- Every non-2xx body is `{ \"error\": \"...\", \"code\": \"...\" }`. Branch on `code`.\n- Every 429 carries a `Retry-After` header, in seconds.\n- Request bodies are capped at 64KB.\n\n## Idempotency\n\n`POST /api/events` accepts an optional `Idempotency-Key` header (up to 200 printable ASCII characters). Retrying with the same key **and the same request body** within 24 hours replays the original 201 - same event id, same admin token - and marks it `Idempotency-Replayed: true` instead of creating a second event. Reusing a key with a different body is `409 conflict`, never someone else's event.\n\nThis matters most when a create succeeds but the response is lost: a blind retry would otherwise fail, because Turnstile tokens are single-use.\n\nKeys are scoped to the caller, which for an anonymous create means the client IP. Callers sharing one NAT or CGNAT address therefore share a key namespace, so **use an unguessable key** - a UUID, not `retry-1`. The stored response is only ever replayed to a byte-identical retry, which is the second lock on that door.","contact":{"name":"WhenWhen support","email":"support@whenwhen.io","url":"https://whenwhen.io/developers"},"license":{"name":"Proprietary","identifier":"LicenseRef-Proprietary"},"termsOfService":"https://whenwhen.io/terms"},"servers":[{"url":"https://whenwhen.io","description":"Production"}],"tags":[{"name":"Meta","description":"Health, configuration, and this document."},{"name":"Events","description":"Creating and reading events."},{"name":"Votes","description":"Participants' answers. No credential beyond the share link."},{"name":"Organizer","description":"Everything the admin token unlocks."},{"name":"Account","description":"Optional sign-in. Raises limits; never required to vote."},{"name":"Tokens","description":"API tokens for agents. Managed from a browser session only."}],"paths":{"/api/health":{"get":{"summary":"Liveness probe","operationId":"getHealth","tags":["Meta"],"security":[],"responses":{"200":{"description":"The worker is serving.","content":{"application/json":{"schema":{"type":"object","required":["ok","service","time"],"properties":{"ok":{"type":"boolean"},"service":{"type":"string"},"time":{"type":"integer","description":"Epoch seconds (UTC). Every timestamp in this API is epoch seconds, never milliseconds or ISO strings."}}}}}}}}},"/api/openapi.json":{"get":{"summary":"This document","operationId":"getOpenApi","tags":["Meta"],"security":[],"responses":{"200":{"description":"The OpenAPI 3.1 description of this API."}}}},"/api/config":{"get":{"summary":"Public browser config","description":"The Turnstile sitekey a browser needs to render the create-page widget. Agents do not need this endpoint.","operationId":"getConfig","tags":["Meta"],"security":[],"responses":{"200":{"description":"Public configuration.","content":{"application/json":{"schema":{"type":"object","required":["turnstileSitekey"],"properties":{"turnstileSitekey":{"type":"string"}}}}}}}}},"/api/events":{"post":{"summary":"Create an event","description":"The only endpoint that requires a credential to call, because a scheduling link on a trusted domain is a phishing vector. There are exactly two ways to pass: a **Turnstile token** in the body (browsers) or an **`Authorization: Bearer ww_…` API token** (agents). Turnstile deliberately rejects non-browsers, which is why the token path exists; there is no third, tokenless programmatic path and there never will be.\n\nMint a token from your account page. Your invitees still need nothing at all - reading, voting, editing and finalizing are uncredentialed.\n\nAnonymous creates land on the instant tier: up to 10 time slots, 10-day lifetime, slots up to 60 days ahead. An authenticated caller - by session or by token, the two are equivalent - creates on their plan's tier: free is 25 slots / 30 days / 180 days ahead, Pro is 100 slots / 365 days / 400 days ahead and may lock in several slots at once.\n\nOne limit spans both dimensions: an event holds at most 20000 answer cells, meaning participants multiplied by time slots. Below Pro no event can reach it. On Pro it means 100 slots come with up to 200 people, and 1000 people come with up to 20 slots.\n\nRate limit: 20 per UTC day per IP anonymously. A token meters against its ACCOUNT's budget INSTEAD of the per-IP one - 20 per UTC day on free, 250 per UTC day on Pro - so a fleet of agents behind one egress address is not throttled at the anonymous rate. The budget belongs to the account rather than the individual token, so several tokens share it.\n\nThe response carries the adminToken exactly once. Store it before doing anything else - it is the only way back into the organizer view.","operationId":"createEvent","tags":["Events"],"security":[{},{"bearerToken":[]}],"parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateEventRequest"}}}},"responses":{"201":{"description":"Created. `Idempotency-Replayed: true` marks a replayed response rather than a fresh create.","headers":{"Idempotency-Replayed":{"description":"Present and \"true\" when this body is a stored replay of an earlier identical request.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateEventResponse"}}}},"400":{"description":"validation - a field failed validation, or the Idempotency-Key is malformed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"forbidden - the supplied API token is unknown or revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"turnstile - the Turnstile token was missing, reused, or rejected.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"conflict - either this Idempotency-Key was already used with a different request body, or the account is at its active-event fair-use ceiling (100 on a free account, 1000 on Pro).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"$ref":"#/components/responses/BodyTooLarge"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"description":"internal","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/events/{id}":{"parameters":[{"$ref":"#/components/parameters/EventId"}],"get":{"parameters":[{"$ref":"#/components/parameters/IfNoneMatch"},{"$ref":"#/components/parameters/ViewToken"}],"summary":"Read an event with its options and votes","description":"Public and uncredentialed: the share link is the capability. Never exposes the admin token, edit tokens, or participant email addresses.\n\nThe exception is a password-protected event (Pro), which answers 403 `locked` until the caller sends a view token from POST /api/events/{id}/unlock. The refusal comes before anything else is read, so a caller without the token learns only that the event exists and is protected.\n\nResponses carry 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 polling loops on this.\n\nThe tag changes whenever anything in the response changes, including the two things that are not writes to the event: the owner changing plan, and the event passing `expiresAt`. A 304 therefore means the whole payload is still current.\n\n`Cache-Control` is `private, no-cache`: intermediaries should not store the payload, and your own client should revalidate rather than reuse a stored copy.\n\nRate limited on two budgets. Per client IP: 60 requests per 60 seconds across both event reads, charged before anything is looked up. Per event: 300 per 60 seconds, charged only when the response has to be rebuilt - a 304 or a cached hit never spends it, so a link in a busy group chat does not throttle itself.\n\nBoth are counted per Cloudflare data centre, so they bound how fast one event can be read from one place rather than enforcing a global quota.","operationId":"getEvent","tags":["Events"],"security":[],"responses":{"200":{"description":"The event, its options in start-time order, and every participant with their votes.","headers":{"ETag":{"$ref":"#/components/headers/ETag"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventView"}}}},"304":{"$ref":"#/components/responses/NotModified"},"403":{"description":"locked - this event is password protected and the request carried no valid view token. POST the password to /api/events/{id}/unlock and retry with the X-View-Token header it returns.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"not_found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/events/{id}/calendar.ics":{"parameters":[{"$ref":"#/components/parameters/EventId"}],"get":{"parameters":[{"$ref":"#/components/parameters/ViewToken"},{"name":"viewToken","in":"query","required":false,"schema":{"type":"string"},"description":"The view token, for callers that cannot set a header. A browser following a download link is the case this exists for; everything else should use X-View-Token, which stays out of request logs and Referer headers."}],"summary":"Download the calendar invite","description":"Available on every tier, no account required - one VEVENT per locked-in slot. 404 until the event is finalized. UIDs are stable across re-finalization, so a re-download updates a previously imported entry in place.\n\nA password-protected event gates this download too. The file carries the title, description, location and the chosen time, which is the whole of what the password protects.","operationId":"getEventIcs","tags":["Events"],"security":[],"responses":{"200":{"description":"RFC 5545 VCALENDAR.","content":{"text/calendar":{"schema":{"type":"string"}}}},"403":{"description":"locked - this event is password protected and the request carried no valid view token. POST the password to /api/events/{id}/unlock and retry with the X-View-Token header it returns.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"not_found - no such event, or it is not finalized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/events/{id}/votes":{"parameters":[{"$ref":"#/components/parameters/EventId"}],"post":{"summary":"Submit votes as a new participant","description":"Uncredentialed by design: holding the share link is the authorization. This is what lets a participant's agent answer on their behalf with nothing but a URL.\n\nOnly while the event is open. Rate limit: 120 per UTC hour per IP. Participants per event are capped by the event's own tier: 100 anonymous, 250 on a free account, 1000 on Pro.\n\nThe editToken in the response is the only way to change this submission later.","operationId":"submitVotes","tags":["Votes"],"security":[],"parameters":[{"$ref":"#/components/parameters/ViewToken"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmitVotesRequest"}}}},"responses":{"201":{"description":"Recorded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmitVotesResponse"}}}},"400":{"description":"validation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"locked - this event is password protected and the request carried no valid view token. POST the password to /api/events/{id}/unlock and retry with the X-View-Token header it returns.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"not_found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"closed - voting is over (finalized or expired); or conflict - the event is at its tier's participant cap, or at the 20000 cell cap (participants multiplied by time slots).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"$ref":"#/components/responses/BodyTooLarge"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/events/{id}/votes/{participantId}":{"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"participantId","in":"path","required":true,"schema":{"type":"string"},"description":"From the submit response."}],"put":{"summary":"Edit a submission","description":"Authorized by the editToken in the body, compared in constant time. Only while the event is open.","operationId":"updateVotes","tags":["Votes"],"security":[],"parameters":[{"$ref":"#/components/parameters/ViewToken"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateVotesRequest"}}}},"responses":{"200":{"description":"Updated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"400":{"description":"validation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"forbidden - wrong edit token; or locked - the event is password protected and the request carried no valid view token. The two are told apart by `code`, and only one of them is worth retrying.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"not_found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"closed - voting is over.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"$ref":"#/components/responses/BodyTooLarge"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/admin/{adminToken}":{"parameters":[{"$ref":"#/components/parameters/AdminToken"}],"get":{"parameters":[{"$ref":"#/components/parameters/IfNoneMatch"}],"summary":"Organizer view","description":"Everything the public view has, plus the share and admin URLs and whether each participant left an email.\n\nResponses carry 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 polling loops on this.\n\nThe tag changes whenever anything in the response changes, including the two things that are not writes to the event: the owner changing plan, and the event passing `expiresAt`. A 304 therefore means the whole payload is still current.\n\n`Cache-Control` is `private, no-cache`: intermediaries should not store the payload, and your own client should revalidate rather than reuse a stored copy.\n\nRate limited on two budgets. Per client IP: 60 requests per 60 seconds across both event reads, charged before anything is looked up. Per event: 300 per 60 seconds, charged only when the response has to be rebuilt - a 304 or a cached hit never spends it, so a link in a busy group chat does not throttle itself.\n\nBoth are counted per Cloudflare data centre, so they bound how fast one event can be read from one place rather than enforcing a global quota.","operationId":"getAdmin","tags":["Organizer"],"security":[],"responses":{"200":{"description":"The organizer view.","headers":{"ETag":{"$ref":"#/components/headers/ETag"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminView"}}}},"304":{"$ref":"#/components/responses/NotModified"},"404":{"description":"not_found - unknown admin token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}},"patch":{"summary":"Add or remove time slots","description":"Adds and removes time slots on an open event. Requires an account: an event created without one returns 400 until it is claimed. Adding a slot records no answer for participants who have already voted; their votes map has no key for it. Removing a slot deletes the votes cast on it and the response reports how many. Returns 409 while the event is finalized (call reopen first) and once it is past expiresAt.","operationId":"editEventOptions","tags":["Organizer"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EditOptionsRequest"}}}},"responses":{"200":{"description":"The event was edited.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EditOptionsResult"}}}},"400":{"description":"validation - unknown option id, a slot outside the accepted window, none left, or more than the tier allows.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"not_found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"closed - the event is finalized or past its lifetime.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"$ref":"#/components/responses/BodyTooLarge"}}},"delete":{"summary":"Delete the event","description":"Hard delete, cascading to options, participants and votes. There is no undo.","operationId":"deleteEvent","tags":["Organizer"],"security":[],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"404":{"description":"not_found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/admin/{adminToken}/finalize":{"parameters":[{"$ref":"#/components/parameters/AdminToken"}],"post":{"summary":"Lock in the winning time slots","description":"Flips the event to finalized and marks the chosen options. Refused once the event is past expiresAt, whatever its stored status.","operationId":"finalizeEvent","tags":["Organizer"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FinalizeRequest"}}}},"responses":{"200":{"description":"Finalized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"400":{"description":"validation - no ids, unknown ids, or more than one without a Pro owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"not_found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"closed - the event is past its lifetime.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"$ref":"#/components/responses/BodyTooLarge"}}}},"/api/admin/{adminToken}/reopen":{"parameters":[{"$ref":"#/components/parameters/AdminToken"}],"post":{"summary":"Reopen a finalized event","description":"Clears every locked-in slot and returns the event to open. Refused past expiresAt, same rule as finalize.","operationId":"reopenEvent","tags":["Organizer"],"security":[],"responses":{"200":{"description":"Reopened.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"404":{"description":"not_found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"closed - the event is past its lifetime.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/report":{"post":{"summary":"Report an event for abuse","description":"For content that breaks the acceptable-use rules in the Terms: spam, harassment, illegal material, or infringement.\n\nNeeds no account and no token, like voting. The event id must name an event that exists.\n\nRate limit: 10 per UTC day per IP. No reporter address is collected, and no reply is sent.","operationId":"reportEvent","tags":["Events"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["eventId","reason"],"properties":{"eventId":{"type":"string"},"reason":{"type":"string","enum":["spam","harassment","illegal","infringement","other"]},"detail":{"type":"string","maxLength":1000,"description":"Optional. What is wrong, in a sentence."}}}}}},"responses":{"201":{"description":"Report filed.","content":{"application/json":{"schema":{"type":"object","required":["reportId"],"properties":{"reportId":{"type":"string"}}}}}},"400":{"description":"validation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"not_found - no such event.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"$ref":"#/components/responses/BodyTooLarge"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/events/{id}/unlock":{"parameters":[{"$ref":"#/components/parameters/EventId"}],"post":{"summary":"Exchange a password for a view token","description":"Some events are password protected (Pro). Reading one, downloading its calendar file and voting on it all answer **403** with code `locked` until the caller proves it knows the password once, here.\n\nHold the `viewToken` that comes back and send it as `X-View-Token` from then on. The password crosses the wire a single time, and a client 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.\n\nThe calendar download also accepts the token as a `viewToken` query parameter, because a browser following a download link cannot attach a header.\n\nWrong passwords are budgeted: 20 per UTC hour per IP, and 60 per UTC hour per event per calling network. Correct ones are refunded, so a whole invited group opening the same event spends nothing.","operationId":"unlockEvent","tags":["Events"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnlockRequest"}}}},"responses":{"200":{"description":"The password was right.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnlockResponse"}}}},"400":{"description":"validation - this event has no password, so there is nothing to unlock.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"forbidden - the password was wrong.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"not_found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"$ref":"#/components/responses/BodyTooLarge"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/auth/login":{"get":{"summary":"Start the hosted sign-in flow","description":"A browser navigation, not an API call: it 302s to the hosted identity provider and back. Add ?intent=signup for the registration screen.","operationId":"authLogin","tags":["Account"],"security":[],"parameters":[{"name":"intent","in":"query","required":false,"schema":{"type":"string","enum":["signup"]}}],"responses":{"302":{"description":"Redirect to the identity provider."},"403":{"description":"forbidden - sign-in must be a top-level navigation, not a subresource load.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/auth/callback":{"get":{"summary":"Sign-in redirect target","description":"Where the identity provider sends the browser back. Not something a client calls directly - it is listed so the sign-in round trip is fully described. Every outcome is a 302 back to /dashboard; failures carry a #autherr= fragment and issue no session.","operationId":"authCallback","tags":["Account"],"security":[],"parameters":[{"name":"code","in":"query","required":false,"schema":{"type":"string"}},{"name":"state","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"302":{"description":"Always. On success a session cookie is set and the redirect carries the email in its fragment; on CSRF, provider or rate-limit failure it carries #autherr= and no session."},"403":{"description":"forbidden - the callback must be a top-level navigation, not a subresource load.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/auth/me":{"get":{"summary":"Who am I","description":"Returns the account id and plan. No email address - the server stores none.","operationId":"authMe","tags":["Account"],"security":[{"sessionCookie":[]},{"bearerToken":[]}],"responses":{"200":{"description":"The signed-in account.","content":{"application/json":{"schema":{"type":"object","required":["user"],"properties":{"user":{"type":"object","required":["id","plan"],"properties":{"id":{"type":"string"},"plan":{"type":"string","enum":["free","pro"]}}}}}}}},"401":{"description":"forbidden - not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/auth/logout":{"post":{"summary":"Sign out","description":"Deletes the session and clears the cookie. Idempotent. There are two sessions and this ends ours immediately; `logoutUrl` is where a browser must then be sent to end the identity provider's SSO session as well, without which the next visitor to that browser is one click from being signed in as you. It is null when the provider session id was never recorded, in which case the local sign-out is the whole of it. Non-browser callers can ignore the URL: the credential this API accepts is already dead by the time the response is written.","operationId":"authLogout","tags":["Account"],"security":[],"responses":{"200":{"description":"Signed out.","content":{"application/json":{"schema":{"type":"object","required":["ok","logoutUrl"],"properties":{"ok":{"type":"boolean","enum":[true]},"logoutUrl":{"type":"string","nullable":true,"description":"The provider's logout endpoint, or null."}}}}}}}}},"/api/auth/account":{"delete":{"summary":"Delete the account","description":"Removes the account and every session. Events are disowned rather than deleted - participants may be mid-vote - and keep working through their existing links until they expire.","operationId":"deleteAccount","tags":["Account"],"security":[{"sessionCookie":[]}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"401":{"description":"forbidden - not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/events/claim":{"post":{"summary":"Adopt anonymous events into your account","description":"Send the admin tokens of events you created without an account and they join this account: the lifetime is re-stamped to your plan (10 days becomes 30 on a free account, a year on Pro, measured from when the event was created) and the event starts counting toward your dashboard. The admin token is the authorization, so this grants nothing you were not already holding. Events that are expired, or already owned by any account including yours, are skipped - re-sending the same tokens is safe and reports 0.","operationId":"claimEvents","tags":["Account"],"security":[{"sessionCookie":[]},{"bearerToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["adminTokens"],"properties":{"adminTokens":{"type":"array","maxItems":50,"items":{"type":"string"}}}}}}},"responses":{"200":{"description":"How many events changed hands.","content":{"application/json":{"schema":{"type":"object","required":["claimed"],"properties":{"claimed":{"type":"integer"}}}}}},"400":{"description":"validation - adminTokens missing, not an array, or over the cap.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"forbidden - not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"conflict - the account is already at its live-event ceiling.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"$ref":"#/components/responses/BodyTooLarge"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/dashboard":{"get":{"summary":"List the account's events","description":"Newest first. A Pro feature - other plans get 403.","operationId":"listMyEvents","tags":["Account"],"security":[{"sessionCookie":[]},{"bearerToken":[]}],"responses":{"200":{"description":"The events this account owns.","content":{"application/json":{"schema":{"type":"object","required":["events"],"properties":{"events":{"type":"array","items":{"type":"object","required":["id","title","status","participantCount","expiresAt","adminToken"],"properties":{"id":{"type":"string"},"title":{"type":"string"},"status":{"type":"string","enum":["open","finalized","expired"]},"participantCount":{"type":"integer"},"expiresAt":{"type":"integer","description":"Epoch seconds (UTC). Every timestamp in this API is epoch seconds, never milliseconds or ISO strings."},"adminToken":{"type":"string"}}}}}}}}},"401":{"description":"forbidden - not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"forbidden - the dashboard is a Pro feature.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/tokens":{"get":{"summary":"List your API tokens","description":"Metadata only. The secret is returned once, by the request that created it, and never again. **Session cookie only**: an API token can never manage tokens, so a leaked one cannot mint a replacement or revoke the one you are watching.","operationId":"listApiTokens","tags":["Tokens"],"security":[{"sessionCookie":[]}],"responses":{"200":{"description":"Your tokens, oldest first, plus how many your plan allows.","content":{"application/json":{"schema":{"type":"object","required":["tokens","limit"],"properties":{"tokens":{"type":"array","items":{"$ref":"#/components/schemas/ApiToken"}},"limit":{"type":"integer","description":"Tokens allowed on this plan: 1 on free, 5 on Pro."}}}}}},"401":{"description":"forbidden - not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Mint an API token","description":"The response is the only time the secret is transmitted; only its SHA-256 is stored. Name it after whatever will hold it, so revoking the right one later is obvious. **Session cookie only.**","operationId":"createApiToken","tags":["Tokens"],"security":[{"sessionCookie":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","minLength":1,"maxLength":60,"examples":["scheduling agent"]}}}}}},"responses":{"201":{"description":"Created. Store `token` now - it is never shown again.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiToken"},{"type":"object","required":["token"],"properties":{"token":{"type":"string","description":"The secret, prefixed `ww_`. Send it as `Authorization: Bearer <token>`."}}}]}}}},"400":{"description":"validation - the name is empty, too long, or contains control characters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"forbidden - not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"conflict - already at the plan's token limit (1 on free, 5 on Pro). Revoke one first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"$ref":"#/components/responses/BodyTooLarge"}}}},"/api/tokens/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The token id (not the secret)."}],"delete":{"summary":"Revoke an API token","description":"Immediate and irreversible - the next request carrying it gets 401. **Session cookie only.**","operationId":"revokeApiToken","tags":["Tokens"],"security":[{"sessionCookie":[]}],"responses":{"200":{"description":"Revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"401":{"description":"forbidden - not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"not_found - no such token on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/support":{"post":{"summary":"Open a support ticket","description":"Rate limited twice: 10 per UTC day per account and 30 per UTC day per IP.","operationId":"submitSupport","tags":["Account"],"security":[{"sessionCookie":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["subject","message","replyTo"],"properties":{"subject":{"type":"string","minLength":1,"maxLength":150},"message":{"type":"string","minLength":1,"maxLength":5000},"replyTo":{"type":"string","format":"email","maxLength":200,"description":"Required - a ticket is unanswerable without one. Stored with the ticket and disclosed on /privacy."}}}}}},"responses":{"201":{"description":"Ticket opened.","content":{"application/json":{"schema":{"type":"object","required":["ticketId"],"properties":{"ticketId":{"type":"string"}}}}}},"400":{"description":"validation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"forbidden - not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"$ref":"#/components/responses/BodyTooLarge"},"429":{"$ref":"#/components/responses/RateLimited"}}}}},"components":{"schemas":{"Error":{"type":"object","required":["error","code"],"description":"Every non-2xx response body in this API has exactly this shape.","properties":{"error":{"type":"string","description":"Human-readable message, safe to show a user."},"code":{"type":"string","enum":["validation","turnstile","rate_limited","not_found","forbidden","closed","conflict","internal","locked"],"description":"Stable machine-readable slug. Branch on this, not on the message text."}}},"Event":{"type":"object","required":["id","title","description","location","durationMin","tz","status","finalizedOptionId","finalizedOptionIds","requireVotes","maxApprovals","createdAt","expiresAt"],"properties":{"id":{"type":"string","description":"12-character URL-safe event id."},"title":{"type":"string","maxLength":120},"description":{"type":["string","null"],"maxLength":2000},"location":{"type":["string","null"],"maxLength":200},"durationMin":{"type":"integer","minimum":5,"maximum":1440,"description":"Default slot length in minutes; an option may override it."},"tz":{"type":"string","description":"The organizer's IANA timezone, e.g. Europe/Berlin. Slot times are still absolute epoch seconds; this is what to label them with."},"status":{"type":"string","enum":["open","finalized","expired"],"description":"Applied lazily on read: an open event past expiresAt reports as expired before the daily sweep writes it."},"finalizedOptionId":{"type":["string","null"],"description":"First (or only) locked-in option. Kept for single-approval readers; prefer finalizedOptionIds."},"finalizedOptionIds":{"type":"array","items":{"type":"string"},"description":"Every locked-in option id, in start-time order. Empty until the organizer finalizes."},"requireVotes":{"type":"boolean","description":"When true, a vote submission must answer every option explicitly."},"maxApprovals":{"type":"integer","minimum":1,"description":"How many slots the organizer may lock in. The slot count for a Pro owner, otherwise 1. Read from the owner's current plan on every request, so it changes when the plan changes."},"createdAt":{"type":"integer","description":"Epoch seconds (UTC). Every timestamp in this API is epoch seconds, never milliseconds or ISO strings."},"expiresAt":{"type":"integer","description":"Voting closes at this time. An event with no decision on it is hard-deleted 30 days after this, or 30 days after its latest proposed slot when that is later. Once an event is finalized the clock moves to the meeting: it is hard-deleted 30 days after the slot that was chosen or 30 days after the decision itself, whichever is later, which on a long lifetime is usually well before this date."}}},"Option":{"type":"object","required":["id","startUtc","durationMin","finalized"],"properties":{"id":{"type":"string"},"startUtc":{"type":"integer","description":"Epoch seconds (UTC). Every timestamp in this API is epoch seconds, never milliseconds or ISO strings."},"durationMin":{"type":["integer","null"],"description":"Per-option override, or null to inherit the event's durationMin."},"finalized":{"type":"boolean","description":"True when this slot is locked in."}}},"Participant":{"type":"object","required":["id","name","votes"],"properties":{"id":{"type":"string"},"name":{"type":"string","maxLength":50},"votes":{"type":"object","description":"Option id → vote value. Participant email addresses are never exposed.","additionalProperties":{"type":"integer","enum":[0,1,2],"description":"0 = No, 1 = If needed, 2 = Yes. Blanks default to 0 unless the event sets requireVotes."}}}},"AdminParticipant":{"allOf":[{"$ref":"#/components/schemas/Participant"},{"type":"object","required":["hasEmail"],"properties":{"hasEmail":{"type":"boolean","description":"Whether this participant left an email. The address itself is never returned."}}}]},"EventView":{"type":"object","required":["event","options","participants"],"properties":{"event":{"$ref":"#/components/schemas/Event"},"options":{"type":"array","items":{"$ref":"#/components/schemas/Option"},"description":"Sorted by startUtc."},"participants":{"type":"array","items":{"$ref":"#/components/schemas/Participant"}}}},"AdminView":{"type":"object","required":["event","options","participants","shareUrl","adminUrl","canEditOptions","password","canSetPassword"],"properties":{"event":{"$ref":"#/components/schemas/Event"},"options":{"type":"array","items":{"$ref":"#/components/schemas/Option"}},"participants":{"type":"array","items":{"$ref":"#/components/schemas/AdminParticipant"}},"shareUrl":{"type":"string","format":"uri","description":"The link to give participants."},"adminUrl":{"type":"string","format":"uri","description":"Private organizer link. Treat as a secret."},"canEditOptions":{"type":"boolean","description":"Whether PATCH /api/admin/{adminToken} will be accepted. Editing the times is an account feature, so this is false on an event created without one until it is claimed."},"password":{"type":["string","null"],"minLength":8,"maxLength":128,"description":"The read password in plain text, or null when anyone with the share link may read the event. This is the only response in the API that returns it, and it is readable so the organizer can answer \"what was it again?\" without changing it and locking out everyone already holding it."},"canSetPassword":{"type":"boolean","description":"Whether a password may be set on this event. True while the owner is on Pro. Clearing one is allowed whatever the plan, so a lapsed subscription never strands participants behind a locked event."}}},"CreateEventRequest":{"type":"object","description":"turnstileToken is required for browser callers and omitted by callers authenticating with an API token; exactly one of the two credentials must be present.","required":["title","durationMin","tz","options"],"properties":{"title":{"type":"string","minLength":1,"maxLength":120},"description":{"type":"string","maxLength":2000},"location":{"type":"string","maxLength":200},"durationMin":{"type":"integer","minimum":5,"maximum":1440},"tz":{"type":"string","description":"IANA timezone name. Rejected if Intl cannot resolve it.","examples":["Europe/Berlin"]},"options":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"integer","description":"Epoch seconds (UTC). Every timestamp in this API is epoch seconds, never milliseconds or ISO strings."},"description":"Proposed start times, epoch seconds, deduplicated server-side. Each must fall between 24 hours ago and the caller's scheduling horizon: 60 days ahead anonymously, 180 on a free account, 400 on Pro. The slot cap is per tier too: 10 anonymous, 25 on a free account, 100 on Pro."},"requireVotes":{"type":"boolean","default":false,"description":"Require an answer for every time slot."},"maxApprovals":{"type":"integer","minimum":1,"deprecated":true,"description":"Deprecated and ignored since 2026-08-03. It used to freeze a per-event finalize ceiling, which could never be corrected afterwards. How many slots may be locked in now follows the owner's plan at finalize time. Still accepted so existing clients do not start failing."},"password":{"type":"string","minLength":8,"maxLength":128,"description":"Protect the event behind a password (Pro). Participants exchange it for a view token at POST /api/events/{id}/unlock and send that token on every read and every vote. Sending this on a create that is not authenticated as a Pro account is 400 `validation`. Trimmed on the way in and on every attempt, so a trailing space never decides whether the event opens."},"turnstileToken":{"type":"string","description":"Required unless the request carries an `Authorization: Bearer ww_…` API token, which replaces it. Browsers obtain one from a Turnstile widget rendered with the sitekey from GET /api/config."}}},"CreateEventResponse":{"type":"object","required":["eventId","adminToken","expiresAt"],"properties":{"eventId":{"type":"string"},"adminToken":{"type":"string","description":"Secret organizer credential - 24 characters. Shown once; there is no way to recover it. Anyone holding it can finalize or delete the event."},"expiresAt":{"type":"integer","description":"Epoch seconds (UTC). Every timestamp in this API is epoch seconds, never milliseconds or ISO strings."}}},"SubmitVotesRequest":{"type":"object","required":["name","votes"],"properties":{"name":{"type":"string","minLength":1,"maxLength":50},"email":{"type":"string","format":"email","maxLength":200,"description":"Optional. Stored for the organizer, never returned by any endpoint."},"votes":{"type":"object","additionalProperties":{"type":"integer","enum":[0,1,2],"description":"0 = No, 1 = If needed, 2 = Yes. Blanks default to 0 unless the event sets requireVotes."},"description":"Option id → vote value. Every key must be an option of this event. Omitted options default to 0 unless the event sets requireVotes, in which case every option must be present."}}},"SubmitVotesResponse":{"type":"object","required":["participantId","editToken"],"properties":{"participantId":{"type":"string"},"editToken":{"type":"string","description":"Secret credential for editing this submission later. Shown once."}}},"UpdateVotesRequest":{"type":"object","required":["editToken"],"description":"Partial update: omit a field to keep its stored value. A supplied votes map replaces the previous answers wholesale.","properties":{"editToken":{"type":"string"},"name":{"type":"string","minLength":1,"maxLength":50},"email":{"type":"string","maxLength":200,"description":"Send an empty string to clear a stored address."},"votes":{"type":"object","additionalProperties":{"type":"integer","enum":[0,1,2],"description":"0 = No, 1 = If needed, 2 = Yes. Blanks default to 0 unless the event sets requireVotes."}}}},"FinalizeRequest":{"type":"object","description":"Locks in the winning slots. The legacy single-slot form { \"optionId\": \"…\" } is still accepted.","properties":{"optionIds":{"type":"array","minItems":1,"items":{"type":"string"},"description":"Option ids after deduplication, all belonging to this event. Re-finalizing replaces the previous selection entirely. More than one requires the owner to be on Pro at that moment."},"optionId":{"type":"string","deprecated":true,"description":"Legacy single-slot form."}}},"EditOptionsRequest":{"type":"object","description":"Adds and removes time slots on an open event. Both fields are optional and an empty body is a no-op. Removals are applied before additions, so a start time freed by a removal can be re-added in the same request.","properties":{"addOptions":{"type":"array","items":{"type":"integer","description":"Epoch seconds (UTC). Every timestamp in this API is epoch seconds, never milliseconds or ISO strings."},"maxItems":100,"description":"Start times to add, epoch seconds, deduplicated against each other and against the slots that survive this edit. Same window as creation, measured against the event's own tier: between 24 hours ago and 60 / 180 / 400 days ahead. The event's total slot count after the edit must stay within its tier cap (10 anonymous, 25 free, 100 Pro), and participants multiplied by slots must stay within 20000."},"removeOptionIds":{"type":"array","items":{"type":"string"},"description":"Option ids to remove. Every id must belong to this event. The votes cast on a removed slot are deleted with it. At least one slot must remain."},"password":{"type":["string","null"],"minLength":8,"maxLength":128,"description":"Set, change or clear the read password (Pro). Omit to leave it alone; send null or an empty string to remove it. A change mints a fresh view token, so every token issued under the old password stops working; resending the current password changes nothing and revokes nothing. Clearing is accepted whatever the plan. Unlike the slot fields, this is accepted on a finalized event, because a leaked password is worth rotating most once the meeting is in everyone's calendar."}}},"EditOptionsResult":{"type":"object","required":["ok","added","removed","votesRemoved","optionCount"],"properties":{"ok":{"type":"boolean","enum":[true]},"added":{"type":"integer","description":"Slots actually added, after deduplication."},"removed":{"type":"integer","description":"Slots removed."},"votesRemoved":{"type":"integer","description":"Votes deleted with the removed slots."},"optionCount":{"type":"integer","description":"Slots on the event after the edit."}}},"Ok":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean","enum":[true]}}},"ApiToken":{"type":"object","required":["id","name","last4","createdAt","lastUsedAt"],"description":"Token metadata. The secret itself is never part of this shape.","properties":{"id":{"type":"string","description":"Public identifier. Use it to revoke."},"name":{"type":"string","maxLength":60},"last4":{"type":"string","description":"Last four characters, to tell tokens apart."},"createdAt":{"type":"integer","description":"Epoch seconds (UTC). Every timestamp in this API is epoch seconds, never milliseconds or ISO strings."},"lastUsedAt":{"type":["integer","null"],"description":"When this token last CREATED an event. Reads are not recorded - metering them would cost a database write on every read."}}},"UnlockRequest":{"type":"object","required":["password"],"properties":{"password":{"type":"string","minLength":8,"maxLength":128,"description":"The password the organizer set. Trimmed before comparison."}}},"UnlockResponse":{"type":"object","required":["viewToken"],"properties":{"viewToken":{"type":"string","description":"Send this as an `X-View-Token` header on every read and vote for this event. It stays valid until the organizer changes the password, so store it and stop sending the password."}}}},"parameters":{"EventId":{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Event id, as returned by create and embedded in the share link."},"AdminToken":{"name":"adminToken","in":"path","required":true,"schema":{"type":"string"},"description":"The secret organizer token from create. Anyone holding it is the organizer."},"IdempotencyKey":{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string","minLength":1,"maxLength":200},"description":"Retry-safety for creates. Reusing a key within 24 hours replays the first response instead of creating a second event."},"IfNoneMatch":{"name":"If-None-Match","in":"header","required":false,"schema":{"type":"string"},"description":"The ETag from a previous read. Unchanged means 304 and no body."},"ViewToken":{"name":"X-View-Token","in":"header","required":false,"schema":{"type":"string"},"description":"From POST /api/events/{id}/unlock. Required on every read and vote for a password-protected event, ignored on every other event."}},"headers":{"ETag":{"required":true,"description":"Weak validator for this representation. Send it back as If-None-Match.","schema":{"type":"string"}}},"responses":{"NotModified":{"description":"Not modified - the representation behind your If-None-Match is still current. No body. Costs one row read, whatever the size of the event.","headers":{"ETag":{"$ref":"#/components/headers/ETag"}}},"BodyTooLarge":{"description":"validation - the request body exceeded the 64KB cap. The limit is applied to every /api route before any handler runs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"RateLimited":{"description":"rate_limited - too many requests. `Retry-After` says how many seconds to wait.","headers":{"Retry-After":{"required":true,"description":"Seconds until the current window resets.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"securitySchemes":{"sessionCookie":{"type":"apiKey","in":"cookie","name":"ww_session","description":"Browser session cookie issued by the sign-in flow."},"bearerToken":{"type":"http","scheme":"bearer","description":"API token minted from your account page, prefixed `ww_`. Unlocks creating events over the API and reading your own account; it deliberately cannot manage tokens or delete the account."}}},"security":[{"sessionCookie":[]}],"externalDocs":{"description":"Developer & agent guide","url":"https://whenwhen.io/developers"}}