For the complete documentation index, see llms.txt.

Poll-Mode Proposals

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:

Two variants

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 full flow

The same five steps work for every poll-shaped app. Substitute your own delivery channel, your own voter identity model, your own confirmation surface.

1. Create

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.

2. Distribute

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.

3. Vote

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:

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.

4. Resolve

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 closePOST /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).

5. Confirm

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.

6. Calendar-add (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.

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.

The Model 1 boundary

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:

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.

Example: a casual group event

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.

Example: demand-side B2B2C

A clinic offering a re-book slot to a patient. The business is the organiser; the patient is the voter.

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.

Provisional contracts

These shapes are documented and stable enough to code against today, but may refine before external GA. Code defensively where flagged.

Both refinements will ship with a deprecation window. Subscribe to release notes.