@vennio/proposals-widget — drop-in iframe widget that embeds the Vennio proposal-response flow in any third-party site. Recipients accept, counter, or decline a meeting proposal in two lines of HTML.
This is the demand-side counterpart to @vennio/widget (supply-side bookings). Where @vennio/widget embeds a shareable-link booking page, @vennio/proposals-widget embeds the magic-link recipient view at vennio.app/p/:token.
Use this widget when your app needs to let a recipient respond to a Vennio proposal — accept, counter, or decline.
Do not call POST /v1/proposals/:id/response directly from a browser app. That endpoint enforces "the organizer cannot respond to their own proposal" — calling it with the organizer's API key returns 403 organizer_cannot_respond. The widget removes the footgun: your app passes the recipient's magic-link token, the widget renders the page, the recipient acts. You never call the response API yourself, so you cannot accidentally call it with the wrong principal.
The raw REST endpoint is still appropriate for server-side flows (webhooks, schedulers, integration adapters) where there is no browser.
<script src="https://unpkg.com/@vennio/proposals-widget"></script>
<div data-vennio-proposal data-token="MAGIC_LINK_TOKEN"></div>
Replace MAGIC_LINK_TOKEN with the token returned by POST /v1/proposals (or extracted from the recipient's proposal-invitation email URL — the <token> part of https://vennio.app/p/<token>).
<div
data-vennio-proposal
data-token="abc123"
data-height="700px"
data-width="100%"
></div>
| Attribute | Default | Description |
|---|---|---|
data-token |
(required) | The magic-link token for the proposal |
data-height |
700px |
Widget height |
data-width |
100% |
Widget width |
data-base-url |
https://vennio.app |
Custom Vennio URL (for self-hosted) |
import { init } from '@vennio/proposals-widget'
const container = document.getElementById('proposal')
const unsubscribe = init(container, {
token: 'abc123',
onAccepted: ({ proposalId, slot }) => {
console.log('Accepted', proposalId, slot)
// slot: { id, start_time, end_time }
},
onCountered: ({ proposalId, counterSlots }) => {
console.log('Countered with', counterSlots)
// counterSlots: [{ start, end, preference? }]
},
onRejected: ({ proposalId }) => {
console.log('Declined', proposalId)
},
onEvent: (event) => {
// Fires for every terminal state, including errors:
// accepted, countered, rejected, expired, used, not_found,
// calendar_disconnected, error
console.log('Event:', event.type, event)
},
})
// Optional: call unsubscribe() to remove the message listener.
// Calling init() again on the same container also auto-unsubscribes
// the previous handler, so SPAs that re-init on token change won't leak.
onAccepted — { proposalId, slot: { id, start_time, end_time } | null }onCountered — { proposalId, counterSlots: [{ start, end, preference? }] }onRejected — { proposalId }onEvent — every event above, plus the terminal/error states. Use this for analytics, fallback UI on expired/used tokens, etc.Note the asymmetry on slot field names: accepted slots carry start_time / end_time (the GET-response shape), while counter slots carry start / end (the POST-request shape). This matches the underlying API contract.
If you're using React, install @vennio/react@0.3.0 or later and use the <VennioProposal> component instead of mounting the vanilla widget by hand:
import { VennioProposal } from '@vennio/react'
<VennioProposal
token={magicLinkToken}
onAccepted={({ proposalId, slot }) => console.log('accepted', slot)}
onCountered={({ proposalId, counterSlots }) => console.log('countered', counterSlots)}
onRejected={({ proposalId }) => console.log('declined')}
onEvent={(event) => console.log(event.type, event)}
/>
The React component's useEffect cleanup handles listener teardown automatically.
The widget reads the magic-link token from a data-token attribute, which means the token is in the rendered HTML and inspectable in DevTools. This is by design. Magic-link tokens are:
401 invalid_token.https://vennio.app/p/:token). The widget does not increase its exposure surface.In short, the token is intentionally a bearer credential with a tiny blast radius. Treat it the way you would a one-time signed URL — don't log it long-term, don't share it across recipients, but don't be afraid to render it.
The widget verifies event.origin on incoming postMessage events, so a malicious page on a different origin cannot spoof acceptance / counter / reject callbacks.
The widget accepts magic-link tokens from any proposal shape — standard, named poll, or open poll. The token determines what the widget renders.
token.kind: 'named') bind to one participant. The widget shows accept / counter / decline (standard) or accept-only (poll, since poll mode rejects counter and decline). The participant row is pre-populated; the voter does not enter a name or email.token.kind: 'open') are multi-use shareable links. The widget shows the slot list plus a voter self-identification form: optional name, optional email, both blank is allowed and recorded as Anonymous. Supplying the same email twice revises the prior vote rather than creating a duplicate row.The token shape comes back on GET /v1/proposals/by-token and the widget switches UI accordingly. Builders embedding the widget don't need to branch by hand — pass the token, get the right form.
<script src="https://unpkg.com/@vennio/proposals-widget"></script>
<div data-vennio-proposal data-token="OPEN_POLL_SHAREABLE_TOKEN"></div>
The same data-token attribute carries both kinds. The example above might be embedded on a public landing page that the organiser shares; the same widget mounted on a private named-invitee page would receive a per-recipient token instead.
onAccepted fires for poll-mode votes too, with the same { proposalId, slot } shape. For open-poll votes, the widget posts the voter-supplied voter_name / voter_email to the API on submit; those values are not echoed back through onAccepted — read them from your own form state if you need them in the host page.
onCountered and onRejected never fire for poll-mode tokens (the API rejects those actions). Listen on onEvent for terminal poll states.
The widget calls these endpoints internally; you do not need to call them yourself:
GET /v1/proposals/by-token?token=… — resolves the token to the proposal payloadPOST /v1/proposals/:id/response — submits accept / counter / reject with the token in the bodyIf you need direct REST access (server-side flows), see Respond to Proposal.