@vennio/proposals-widget
v0.1.1
Published
Embeddable proposal-response widget for Vennio — recipients accept, counter, or decline a meeting proposal with 2 lines of HTML
Downloads
15
Maintainers
Readme
@vennio/proposals-widget
Embed a Vennio proposal-response widget with 2 lines of HTML. Recipients accept, counter, or decline a meeting proposal — no framework required, no API keys, no architectural choices.
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.
Quick Start
<script src="https://unpkg.com/@vennio/proposals-widget"></script>
<div data-vennio-proposal data-token="MAGIC_LINK_TOKEN"></div>That's it. Replace MAGIC_LINK_TOKEN with the token from a Vennio proposal-invitation email (or the token field returned by POST /v1/proposals).
Why this widget exists
Vennio's proposal API has a deliberate guardrail: the proposal organizer cannot respond to their own proposal. Responses come from recipients, authenticated via magic-link token — not via the organizer's API key. Calling POST /v1/proposals/:id/response with the organizer's key returns 403 organizer_cannot_respond.
This widget removes that footgun by embedding the recipient flow directly. Your app passes the token, the widget renders the page, the recipient acts. You never call the response API yourself, so you can never accidentally call it as the wrong principal.
Options
<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) |
Programmatic Usage
import { init } from '@vennio/proposals-widget'
const container = document.getElementById('proposal')
const unsubscribe = init(container, {
token: 'abc123',
height: '700px',
onAccepted: ({ proposalId, slot }) => {
console.log('Accepted', proposalId, slot)
},
onCountered: ({ proposalId, counterSlots }) => {
console.log('Countered with', counterSlots)
},
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.Event payloads
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 when you need to react to expiry, already-used tokens, calendar-disconnected recipients, or fetch errors.
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.
React
If you're using React, install @vennio/react and use the <VennioProposal> component instead of mounting this widget by hand:
import { VennioProposal } from '@vennio/react'
<VennioProposal
token={token}
onAccepted={({ slot }) => console.log('accepted', slot)}
onCountered={({ counterSlots }) => console.log('countered', counterSlots)}
onRejected={() => console.log('rejected')}
/>Security: the token in the DOM
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:
- Single-use. The token is consumed atomically the moment the recipient submits an accept/counter/reject. A second attempt returns
401 invalid_token. - Recipient-scoped. A token binds one specific recipient to one specific proposal. It cannot be used to read or respond to anything else.
- Already-public-by-design. The same token travels through the recipient's email inbox in the proposal-invitation URL (
https://vennio.app/p/:token). The widget does not increase its exposure surface.
In other words, 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.
Building from source
cd packages/proposals-widget
npm install
npm run buildThe build emits three formats:
dist/widget.global.js— IIFE for<script src>tags (default unpkg/jsdelivr target)dist/widget.mjs— ESM for bundlersdist/widget.js— CJS
License
MIT
