A poll proposal collects votes against a fixed set of candidate slots via magic links. Voters don't need a Vennio account. Recipients don't need to grant calendar consent. The organiser proposes; voters pick; the API resolves.
This is the demand-side variant of Vennio's proposal primitive: the organiser knows (or guesses) what works for the recipients and asks them to confirm. Compare with standard mode, where the proposal is bound to known Vennio accounts, the recipient's calendar is consulted, and the first accept ends it.
Use poll mode when:
There is one mode: poll. It splits at create time on whether you supply a list of invitees.
| Named poll | Open poll | |
|---|---|---|
| Trigger | mode: poll + non-empty participant_emails |
mode: poll, participant_emails absent or empty |
| Magic links | One per invitee, single-use | One shareable link, multi-use |
| Voter identity | Bound to the invited email | Self-supplied at vote time (voter_name, voter_email) or anonymous |
| Resolves on all-voted | Yes | No — voter set is open-ended |
| Resolves on close / expiry | Yes | Yes |
Pick named when you know who you're inviting and want a per-recipient credential. Pick open when you want one URL that anyone you share it with can vote on.
The same five steps work for every poll-shaped app. Substitute your own delivery channel, your own voter identity model, your own confirmation surface.
POST /v1/proposals with organiser auth (Bearer JWT or vennio_sk_live_* API key).
curl -X POST https://api.vennio.app/v1/proposals \
-H "Authorization: Bearer $VENNIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"mode": "poll",
"message": "Q3 board call",
"participant_emails": ["a@example.com", "b@example.com", "c@example.com"],
"slots": [
{ "start": "2026-06-10T14:00:00Z", "end": "2026-06-10T15:00:00Z" },
{ "start": "2026-06-11T14:00:00Z", "end": "2026-06-11T15:00:00Z" }
]
}'
curl -X POST https://api.vennio.app/v1/proposals \
-H "Authorization: Bearer $VENNIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"mode": "poll",
"message": "Friday night",
"slots": [
{ "start": "2026-06-12T19:00:00Z", "end": "2026-06-12T21:00:00Z" },
{ "start": "2026-06-12T20:00:00Z", "end": "2026-06-12T22:00:00Z" }
]
}'
Named-poll responses return { proposal, invitations: [{ participant_email, magic_link_url, expires_at }, ...] }. Open-poll responses return { proposal, magic_link_url }.
Poll-mode expiry floor. If the earliest proposed slot starts within roughly 25 hours of now, the create call returns 400 earliest_slot_too_soon. The reaper needs room to resolve a poll before its winning slot begins; an unreachable floor would race vote-collection against the slot. Propose a later earliest slot or switch to mode: standard.
expires_in_hours is ignored in poll mode. expires_at is computed as min(earliest_slot - 24h, now + 48h) — voting always ends at least 24 hours before the earliest slot, and never more than 48 hours from creation. The 48-hour cap matters for far-future polls: a slot 30 days out still gets a 48-hour voting window, not 29 days. Plan delivery and reminder cadences against the 48-hour ceiling, not the slot date.
You own delivery. The API mints magic links; it does not message voters. Vennio doesn't know how your voters prefer to be reached — chat, SMS, push, in-app banner, paper — so it doesn't try. This is the boundary that makes the primitive composable; see The Model 1 boundary below.
A named poll gives you invitations[] — one tuple per invitee. Fan them out by whatever channel you use. An open poll gives you one URL — the value returned in magic_link_url. Post it, paste it, embed it.
The link points at a Vennio-hosted voting page at /p/<token> on the portal host (https://app.vennio.app/p/<token> in production). The page renders the candidate slots, the voter self-identification form (open polls only), and submits the vote against POST /v1/proposals/{id}/response. Use magic_link_url from the response directly rather than constructing it yourself — the host can change.
POST /v1/proposals/{id}/response with the magic-link token in the body. The action is always accept — poll mode does not support counter or reject (returns 400 poll_mode_only_supports_accept).
curl -X POST https://api.vennio.app/v1/proposals/prop_xxx/response \
-H "Content-Type: application/json" \
-d '{
"token": "MAGIC_LINK_TOKEN",
"action": "accept",
"selected_slot_id": "slot_abc123",
"voter_name": "Jess",
"voter_email": "jess@example.com"
}'
For named tokens, identity comes from the bound participant row — voter_name / voter_email are ignored.
For open tokens:
voter_name and voter_email are optional. Omit both and the row is recorded as Anonymous.voter_email lets the same voter revise their vote: a second submission with the same email updates the existing row's selected_slot_id instead of creating a duplicate. Without an email, every submission creates a new participant row.Rate limit. Open-poll tokens are multi-use, so the API rate-limits per token (default 20 votes per 60 seconds). Builders who want stricter voter dedup should layer their own identity check before calling the API.
A poll resolves on one of three triggers. All three end at the same place: status: 'accepted' with the winning slot's is_selected: true.
| Trigger | Named | Open |
|---|---|---|
| All-voted — every named invitee has voted | Yes | n/a |
Organiser close — POST /v1/proposals/{id}/close |
Yes | Yes |
Expiry — reaper, at expires_at |
Yes | Yes |
The winning slot is the one with the most votes. Ties are broken by earliest start_time.
Zero-vote resolution paths short-circuit to status: 'expired' — both zero-vote close and zero-vote expiry skip the resolver entirely.
curl -X POST https://api.vennio.app/v1/proposals/prop_xxx/close \
-H "Authorization: Bearer $VENNIO_API_KEY"
Close is poll-mode only. For standard proposals use /cancel, which has different semantics (it cancels an already-accepted booking and tears down calendar events).
When a poll resolves, the API fires proposal.resolved to the organiser's webhook subscribers, post-commit, after proposals.status='accepted' is durable. The same event fires for all three triggers; consumers don't have to branch on cause.
{
"id": "evt_abc123",
"type": "proposal.resolved",
"created_at": "2026-06-10T13:00:00Z",
"data": {
"proposal_id": "prop_xxx",
"thread_id": "thr_xxx",
"poll_variant": "open",
"winning_slot": {
"id": "slot_abc123",
"start": "2026-06-12T19:00:00Z",
"end": "2026-06-12T21:00:00Z"
},
"vote_count": 4,
"total_votes": 6,
"tie_count": 1
}
}
tie_count is the number of slots that finished on the winning vote count (1 means a clean winner; 2+ means the earliest-start tie-break decided it). Useful for surfacing "it was close" UI to the organiser.
Voters without Vennio accounts are unreachable via webhooks by design. Fan-out to voters happens in your code — the webhook tells you which slot won; your delivery layer tells the voters.
GET /v1/proposals/{id}/ics?token=... returns text/calendar so a voter can tap their magic-link URL on a phone and add the meeting to their calendar without an email round-trip.
409 poll_not_resolved until the poll resolves.410 token_expired after the token's expiry.401 invalid_token on a bad token.The iCalendar UID is deterministic: proposal-{proposalId}-slot-{slotId}@vennio.app. Every retrieval of the same resolved poll produces the same UID, so calendar clients reconcile organiser-side edits against a single shared event identity rather than creating duplicates.
Poll mode draws an explicit line between what the API knows and what your code owns.
The API knows: which slots, which voters voted (named identity for named polls; self-supplied or anonymous for open), when each vote landed, the resolved winner, and the resolution trigger.
The API does not know: the full invitee list for open polls, how any voter prefers to be reached, who has and hasn't seen the link, the delivery channel, or your app's notion of identity.
Practical implications:
voter_email as a hint, not a verified credential.If you find yourself wishing the API would deliver something to voters, that's the boundary. The primitive's value is that it's composable with any channel — chat bot, SMS gateway, push notification, mailshot — precisely because it doesn't pick one.
A "what night this week?" app for friends. The organiser posts one link in a group chat; anyone in the chat votes; the organiser closes it when they've seen enough.
magic_link_url directly into the chat thread.voter_name; that's a UX call, not a contract change.proposal.resolved fires; the app posts the winner back to the chat thread and offers each voter an "add to calendar" link that hits GET /v1/proposals/{id}/ics.A clinic offering a re-book slot to a patient. The business is the organiser; the patient is the voter.
voter_email (patient's email on file) prefilled by the clinic's web form for open.proposal.resolved triggers the clinic's EHR to write the appointment. The patient gets the .ics link to add it to their phone.The inversion: a traditional booking page asks the patient to pick from the clinic's free slots. A poll proposal lets the clinic propose specific slots the clinic wants the patient to take (a re-book of a no-show, a procedure that needs a specific room, a slot the clinic wants to fill) and asks the patient to confirm. Same primitive, different power dynamic.
These shapes are documented and stable enough to code against today, but may refine before external GA. Code defensively where flagged.
.ics content. The UID format and voter-only attendee roster are locked. The title (currently hard-coded Vennio meeting), the description format, and the ATTENDEE / PARTSTAT polish are provisional pending validation against real Apple, Google, and Outlook clients. The title will become customisable before external GA.status: 'accepted' for resolved polls. Resolved polls share the standard-mode terminal status, disambiguated by mode: 'poll'. This may split into a distinct resolved status before external GA. If you switch on terminal status, also switch on mode.Both refinements will ship with a deprecation window. Subscribe to release notes.