API
Venn's JSON API is served under /api by both the Cloudflare Worker and the Node server. Writes must be sent as application/json. Times are UTC milliseconds.
Endpoints
| Method | Path | Notes |
|---|---|---|
POST |
/api/events |
Create a poll → { id, adminToken }. Specific times: { mode: "options", options: [{ start, end }] } (UTC ms; up to 14 days long). Whole days: slotMinutes: 1440, with durationMinutes a multiple of 1440 to look for several days in a row. Hours that differ by day: windows: [{ date, start, end }] (minutes after midnight in the poll's time zone), with slotMinutes (default 30) and durationMinutes. Parts of the day: layout: "parts", each option a morning (9–12), afternoon (12–5) or evening (6–10). Optional location and emoji, the privacy settings below, and for the next one of a plan seriesOf. hostAnswers: true saves the organizer's own answer and returns it as participant: { id, editToken }: hostAvailability (option start → 2 or 1) if given, otherwise yes to every time offered. (The app sends it, from how the organizer marked each option. Without it, the organizer's first answer sent with x-admin-token becomes their own.) |
GET |
/api/events/:id |
Poll and responses. Sending the x-admin-token header sets isAdmin; x-edit-token gets your own answer back in full. |
PUT |
/api/events/:id |
Change the poll: the same body as creating one. Organizer only, and only while nobody but the organizer has answered (409 after that). The organizer's own answer keeps the times still in the poll. |
DELETE |
/api/events/:id |
Organizer only |
PUT |
/api/events/:id/settings |
{ requireEmail?, hideNames?, hideResults? }: each privacy setting true or false → all three. Organizer only. |
POST |
/api/events/:id/participants |
{ name, availability, email?, copyTo? } → { id, editToken }. email is required when the poll has requireEmail. copyTo emails the person a copy. |
PUT / DELETE |
/api/events/:id/participants/:pid |
Needs x-edit-token. The organizer can also delete. |
POST |
/api/events/:id/finalize |
{ start }, or { start: null } to clear. Organizer only. |
GET |
/api/events/:id/event.ics |
Calendar file for the picked time |
PUT / DELETE |
/api/events/:id/image |
The poll's own picture, in place of its emoji: { icon, card? }, each base64 or a data: URL, → { image: { v, card, type } }. DELETE goes back to the emoji. Organizer only. See The poll's picture. |
GET |
/api/events/:id/icon.jpg, card.jpg |
The poll's picture and its link-preview card, for anyone with the link. Add ?v= with image.v to have it cached for good. |
GET / PUT |
/api/events/:id/notifications |
Email and webhook settings. Organizer only. See Notifications. |
POST |
/api/events/:id/notifications/test |
Send a test webhook. Organizer only. |
GET |
/api/config |
Which optional features (email, calendars, AI, usage stats) this server has turned on |
POST |
/api/usage |
A usage count from the browser: { name, props }. Only known events are kept; see Usage stats |
GET |
/api/stats?days=30 |
Usage stats, with authorization: Bearer <STATS_TOKEN>. See Usage stats |
POST |
/api/ai/draft-poll |
{ prompt, timezone } → a draft poll. See AI features. |
POST |
/api/events/:id/ai/:kind |
summary and message (organizer only), answer (anyone) |
Privacy settings
Organizers can switch these on when creating a poll (POST /api/events) or later (PUT /api/events/:id/settings). The server enforces them, so hidden data never reaches other guests:
| Setting | What it does |
|---|---|
requireEmail |
Everyone who answers must send an email. Only the organizer sees it: each response has an email field for the organizer, and for the person who gave it. |
hideNames |
Other guests see each response named Guest 1, Guest 2… The answers still show, so everyone can see which dates work. |
hideResults |
Other guests get only their own response, plus responses: how many people have answered. The picked date still shows once the organizer picks it. |
The next one of a plan
Book clubs, run clubs and game nights come round again. Once the date is picked, the organizer's "Plan the next one" opens /new/pick?copy=<id>&next (or /new/free): the poll copied, with dates that have passed dropped, or all moved on by whole weeks when none are left, at the same local times whatever daylight saving does. The code is in public/copy-poll.js. A plain copy (?copy=<id>, "Duplicate poll") does the same without &next.
| Field | What it does |
|---|---|
seriesOf |
The id of the poll this one follows on from, sent by the app for &next. If that poll no longer exists it's left out. Kept when the poll is edited. |
Its organizer can then bring last time's group back from the new poll's page. Polls used to say how often they repeated (repeat), with an email to the organizer when it was time; that was taken out, and repeat is now ignored.
Availability
availability maps each option's start time (UTC ms) to 2 (yes) or 1 (if needed). Anything missing means no.
{ "name": "Ana", "availability": { "1793804400000": 2, "1793818800000": 1 } }
The poll's picture
An organizer can give a poll any emoji (emoji when creating or changing it: one emoji, nothing else) or a picture of their own. The web app makes the picture in the browser: the middle square at 512 × 512, and a 1200 × 630 card with the poll's name for link previews, both JPEG. It uploads them with PUT /api/events/:id/image once the poll exists, and again when the poll is edited (so the card has the new name) or copied.
iconis required, at most 200 KB;cardis optional, at most 300 KB. Each must be a JPEG, PNG or WebP, which the server checks from its first bytes, not from what it says it is.GET /api/events/:idthen includesimage: { v, card, type }.vchanges whenever the picture does.icon.jpgandcard.jpgserve the picture as itself (content-typefrom its bytes,x-content-type-options: nosniff, and acontent-security-policyofdefault-src 'none'; sandbox). With the current?v=they're cached for a year; without it, checked each time.- With a card, the poll's link preview (
og:image) iscard.jpg?v=…; without one, Venn's own. The picked date'sevent.icslists the icon as an RFC 7986IMAGE(DISPLAY=BADGE). - Deleting the poll deletes its picture.
Authentication
There are no accounts. Two secrets, both returned once when they're created, prove who you are:
adminToken(fromPOST /api/events): send it asx-admin-tokenfor organizer-only calls.editToken(fromPOST /api/events/:id/participants): send it asx-edit-tokento change or remove that response.
The server stores only SHA-256 hashes of these tokens. In the web app they live in the URL fragment and the browser's local storage.
Errors
Errors come back as JSON with an HTTP status: { "error": "This poll does not exist or was deleted" }. Validation problems are 400, missing permissions 403, and unknown polls 404.