# Vennio API Documentation
Base URL: https://api.vennio.app
API Version: 1.5.0
Vennio provides AI-native scheduling infrastructure that makes time programmable. Build scheduling features into any product — availability queries, direct bookings, paid bookings via Stripe, webhook events, and multi-provider calendar sync (Google Calendar, Microsoft 365).
---
# Guides
# Introduction to the Vennio API
**Schedule on behalf of anyone — with their consent.** Vennio is scheduling infrastructure for developers and AI agents: check real availability, propose the right time, and confirm a booking without anyone going back and forth.
Vennio API v1.5.0
The current stable API version is **1.5.0**. All endpoints are backwards compatible within the v1 series.
## What is Vennio?
Vennio is scheduling infrastructure for developers. Connect your users' calendars, query real availability across multiple people and time zones, and create bookings — all via simple REST API calls. Vennio handles the complexity of multi-provider calendar integrations (Google Calendar, Microsoft 365), conflict detection, confirmation emails, CRM sync, and webhook delivery.
## Key capabilities
- **Availability queries** — Find open slots across multiple calendars in any timezone
- **Direct bookings** — Create calendar events and send confirmation emails in one API call
- **Venn Links** — Shareable booking pages (like Calendly links) created programmatically
- **Multi-host accounts** — Manage many independently-bookable hosts (coaches, agents, practitioners) under one account, each booked and attributed separately
- **Paid bookings** — Collect payments via Stripe before confirming bookings
- **Consent management** — GDPR-compliant, fine-grained calendar access permissions
- **Webhook events** — Real-time notifications for bookings, cancellations, and consent changes
- **CRM sync** — Automatic Salesforce/HubSpot record creation on booking
- **AI-agent friendly** — Structured responses, natural language descriptions, MCP support
## How it works
A typical booking flow takes 3 API calls:
1. Query `GET /v1/availability/slots` to find open times
2. Present slots to the end-user for selection
3. Call `POST /v1/bookings` to create the booking
Vennio synchronously creates the calendar event, sends confirmation emails to both parties, and fires a `booking.created` webhook — all in the same response.
For paid Venn Links, the booking enters `pending_payment` status and returns a `checkout_url`. The booking confirms automatically after payment via Stripe Checkout.
## Base URL
```bash
# Production
https://api.vennio.app
# Local development (run: cd api && node server.js)
http://localhost:3001
```
## Next steps
- [Authentication](/overview/authentication) — Get your API key
- [Quick Start](/overview/quickstart) — Make your first booking in 60 seconds
- [Paid Bookings](/overview/paid-bookings) — Accept payments via Stripe Connect
- [Platform APIs](/api/platform-apis) — Availability and booking endpoints
---
# Authentication
All Vennio API requests require authentication using a Bearer token in the `Authorization` header.
## API keys
Vennio API keys are the recommended authentication method for server-side integrations and AI agents. Keys start with `vennio_sk_live_` (secret) or `vennio_pk_live_` (publishable).
Keep secret keys private
Secret keys (`vennio_sk_live_*`) should never be exposed in client-side code, browser JavaScript, or mobile apps. For client-side booking widgets, use a publishable key — it reaches only the public booking surface, so exposing it in a browser is safe.
## Key types
| Key type | Prefix | Use case | Access |
|---|---|---|---|
| **Secret key** | `vennio_sk_live_` | Server-side, AI agents | Full access |
| **Publishable key** | `vennio_pk_live_` | Client-side booking widgets | Public booking surface only; domain-lockable |
## Creating a key
First API key? Use the dashboard
Create your first API key at [vennio.app/api-keys](https://vennio.app/api-keys). No API call required. Once you have a secret key, you can create additional keys programmatically using `POST /v1/api-keys` — pass your existing secret key as the Bearer token.
```bash
curl -X POST https://api.vennio.app/v1/api-keys \
-H "Authorization: Bearer vennio_sk_live_YOUR_EXISTING_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Production key",
"description": "For my scheduling integration",
"rate_limit_per_hour": 1000
}'
```
```json title="Response"
{
"api_key": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Production key",
"key": "vennio_sk_live_xxxxxxxxxxxxxxxxxxxx",
"scopes": ["read", "write"],
"rate_limit_per_hour": 1000,
"created_at": "2026-01-20T10:00:00Z"
}
}
```
Save your key immediately
The full key value is only shown once at creation. Store it securely — you won't be able to retrieve it again.
## Using your key
Pass your API key as a Bearer token in every request:
```bash
curl https://api.vennio.app/v1/availability/slots \
-H "Authorization: Bearer vennio_sk_live_YOUR_KEY_HERE" \
-G \
-d business_id=YOUR_BUSINESS_ID \
-d duration_minutes=30 \
-d from=2026-05-01T09:00:00Z \
-d to=2026-05-01T17:00:00Z
```
## What each key can reach
Secret keys and user sessions (JWT) have full access. A **publishable key has no access beyond the public booking surface** — the same endpoints an anonymous visitor to a booking page can reach: resolve a booking link, read its availability, create a booking, join a waitlist. It cannot create or manage links, hosts, consents, proposals, webhooks, or any account resource.
A publishable key can also read `GET /v1/availability/slots` and `GET /v1/availability/next` for **your own account and the hosts your account manages**. It cannot read the availability of people connected to you or who have granted you consent. That needs a secret key or a user session, as do `/v1/availability/mutual`, `/v1/principals/{id}/availability` and `/v1/network`.
**What it's for:** embed it in client-side code and lock it to your domains. It identifies your account on the public booking surface — so bookings land against the right business — without carrying any power worth stealing. That's the point: safe in a browser, useful, and origin-restricted. Its value is operational:
- **Domain-locking** — restrict a key to specific origins via `allowed_domains`; requests from other origins are rejected.
- **Higher rate tier** — 1000 requests/hour, versus 100 for secret keys, sized for browser traffic.
Using a publishable key on any endpoint outside the public surface returns the standard problem+json 403:
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "This endpoint requires a secret API key. Publishable keys cannot access this resource. Use a secret key (vennio_sk_live_...) or a user session for server-side operations.",
"request_id": "req_abc123"
}
```
## Security best practices
- Store API keys in environment variables, never in source code
- Rotate keys immediately if compromised — `DELETE /v1/api-keys/{id}`
- Use separate keys per environment (dev, staging, production)
- Use a publishable key (never a secret key) in browser or client code — it only reaches the public booking surface
- Monitor usage via `GET /v1/api-keys/{id}/stats`
---
# Onboarding
This guide walks you from a blank slate to your first confirmed booking via the API.
## 1. Sign up
Create an account at [vennio.app](https://vennio.app).
## 2. Connect your calendar
Go to **Dashboard → Connect Calendar** and connect Google or Microsoft. This is required before the API can create bookings — Vennio checks your calendar to avoid conflicts.
## 3. Get an API key
Go to **Dashboard → API Keys → Create API Key**. Your key will look like `vennio_sk_live_...`.
Keep this secret — it grants full access to your account.
## 4. Get your business_id
Your `business_id` is needed for availability queries and booking creation. Fetch it from the API:
```bash
curl https://api.vennio.app/v1/me \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
The response includes `"principal_id": "your-business-id"`. Store this value.
## 5. Check availability
Query the API for open slots:
```bash
curl "https://api.vennio.app/v1/availability/slots?business_id=your-business-id&duration_minutes=30&from=2026-04-07T09:00:00Z&to=2026-04-07T17:00:00Z" \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
The response returns an array of `{ start, end }` slot objects.
## 6. Create a booking
Pick a slot and create a booking:
```bash
curl -X POST https://api.vennio.app/v1/bookings \
-H "Authorization: Bearer $VENNIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"business_id": "your-business-id",
"customer_email": "customer@example.com",
"customer_name": "Jane Doe",
"start_time": "2026-04-07T10:00:00Z",
"end_time": "2026-04-07T10:30:00Z"
}'
```
A `201` response with the booking object means you're done.
## Next steps
- [Webhooks](/guides/webhooks) — get notified when bookings are confirmed, cancelled, or rescheduled
- [JavaScript SDK](/sdks/javascript) — use the SDK instead of raw curl
- [Booking Widget](/guides/booking-widget) — embed a booking flow on your website in two lines of HTML
---
# Quick Start
Get an API key. Create a booking. List bookings. Check your calendar. Under 5 minutes.
## Prerequisites
- A Vennio account — sign up at [vennio.app](https://vennio.app)
- A connected Google or Microsoft calendar (connect in the dashboard after signup)
---
## Step 1 — Get your API key
Create your first API key at [vennio.app/api-keys](https://vennio.app/api-keys). Copy the secret key — it starts with `vennio_sk_live_`.
```bash
export VENNIO_API_KEY="vennio_sk_live_YOUR_KEY"
export BUSINESS_ID="YOUR_BUSINESS_ID"
```
Your business ID is shown on the dashboard home page.
Keep secret keys server-side
Secret keys (`vennio_sk_live_*`) must never be exposed in client-side code or version control. Store them in environment variables.
---
## Step 2 — Create a booking
```bash
curl -X POST https://api.vennio.app/v1/bookings \
-H "Authorization: Bearer $VENNIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"business_id": "'"$BUSINESS_ID"'",
"customer_email": "customer@example.com",
"customer_name": "Jane Doe",
"start_time": "2026-05-01T09:00:00Z",
"end_time": "2026-05-01T09:30:00Z",
"notes": "Intro call"
}'
```
```json title="Response (201 Created)"
{
"booking": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "confirmed",
"business_id": "YOUR_BUSINESS_ID",
"customer_name": "Jane Doe",
"customer_email": "customer@example.com",
"start_time": "2026-05-01T09:00:00Z",
"end_time": "2026-05-01T09:30:00Z",
"created_at": "2026-04-11T10:00:00Z"
}
}
```
Vennio automatically creates a calendar event, sends confirmation emails, and fires a `booking.created` webhook.
Testing without side effects
Add `"demo_mode": true` to your request body to run the full API flow without creating a calendar event or sending emails. Use this in development and CI.
---
## Step 3 — List your bookings
```bash
curl "https://api.vennio.app/v1/bookings?business_id=$BUSINESS_ID&status=confirmed&limit=10" \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
```json title="Response"
{
"bookings": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "confirmed",
"customer_name": "Jane Doe",
"customer_email": "customer@example.com",
"start_time": "2026-05-01T09:00:00Z",
"end_time": "2026-05-01T09:30:00Z"
}
],
"pagination": {
"has_more": false,
"next_cursor": null,
"total_count": 1
}
}
```
Filter parameters:
| Parameter | Type | Description |
|---|---|---|
| `business_id` | UUID | Filter by business |
| `status` | string | `confirmed`, `cancelled`, or `completed` |
| `from` | ISO 8601 | Bookings starting after this time |
| `to` | ISO 8601 | Bookings starting before this time |
| `limit` | integer | Page size (default: 20, max: 100) |
| `cursor` | string | Pagination cursor from previous response |
---
## Step 4 — Check your calendar connection
List connected calendars to confirm Vennio can read and write availability:
```bash
curl https://api.vennio.app/v1/calendars \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
```json title="Response"
{
"providers": [
{
"provider": "google",
"calendars": [
{
"id": "you@gmail.com",
"name": "you@gmail.com",
"primary": true,
"is_selected": true
}
],
"count": 1
}
],
"total_providers": 1,
"failed_providers": 0,
"total_calendars": 1
}
```
If no calendars are connected, go to the dashboard and complete the Google or Microsoft OAuth flow. See [Integration Patterns](/guides/integration-patterns) for embedding the connect flow in your product.
To manually trigger a sync:
```bash
curl -X POST https://api.vennio.app/v1/calendars/sync \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
```json title="Response"
{
"success": true,
"synced_providers": 1,
"failed_providers": 0,
"total_blocks": 24,
"total_calendars": 1,
"providers": [ "..." ]
}
```
---
## SDK alternative
If you prefer typed methods over raw HTTP, install `@vennio/sdk`:
```bash
npm install @vennio/sdk
```
```javascript title="booking.js"
import { createVennioClient } from '@vennio/sdk'
const client = createVennioClient({ apiKey: process.env.VENNIO_API_KEY })
// Create a booking
const res = await client.bookings.create({
body: {
business_id: process.env.BUSINESS_ID,
customer_email: 'customer@example.com',
customer_name: 'Jane Doe',
start_time: '2026-05-01T09:00:00Z',
end_time: '2026-05-01T09:30:00Z',
},
})
console.log('Booking created:', res.data.booking.id)
// List bookings
const list = await client.bookings.list({
query: { business_id: process.env.BUSINESS_ID, status: 'confirmed' },
})
console.log(`${list.data.bookings.length} bookings found`)
```
---
## What you built
In 4 steps you:
- Created an API key and set up auth
- Created a confirmed booking (calendar event + emails included)
- Listed bookings with filters
- Verified your calendar connection and triggered a sync
## Next steps
- [Authentication](/overview/authentication) — key types, scopes, and rotation
- [Integration Patterns](/guides/integration-patterns) — calendar sync OAuth flow, webhook setup, timezone handling
- [Rate Limits & Errors](/overview/rate-limits) — error codes, retry logic, and ProblemDetail format
- [Webhooks](/overview/webhooks) — subscribe to `booking.created`, `booking.cancelled`, and more
- [React Components](/sdks/react) — drop a booking widget into your app with `@vennio/react`
---
# Rate Limits & Errors
## Error response format
All Vennio API errors follow [RFC 9457 Problem Detail](https://www.rfc-editor.org/rfc/rfc9457) format:
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Email is required",
"request_id": "req_abc123",
"errors": {
"customer_email": "Required field missing"
}
}
```
| Field | Description |
|---|---|
| `type` | URI identifying the error type — stable across API versions |
| `title` | Short human-readable summary |
| `status` | HTTP status code |
| `detail` | Specific explanation for this error instance |
| `request_id` | Trace ID — include this when contacting support |
| `errors` | Field-level validation errors (400 only) |
---
## HTTP status codes
| Status | Meaning | Retryable? |
|---|---|---|
| 400 | Bad request — invalid or missing parameters | No — fix the request |
| 401 | Unauthorized — missing or invalid API key | No — check your key |
| 403 | Forbidden — API key lacks required scope | No — use a key with correct scopes |
| 404 | Not found — resource doesn't exist, or calendar not connected | No |
| 409 | Conflict — slot no longer available | No — re-query availability |
| 422 | Unprocessable — valid request that cannot be executed (e.g. past time) | No — fix the request |
| 429 | Too many requests — rate limit exceeded | Yes — wait for reset |
| 500 | Internal server error | Yes — retry with backoff |
| 503 | Service unavailable | Yes — retry with backoff |
---
## Common errors by scenario
### Invalid or missing API key (401)
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid API key",
"request_id": "req_abc123"
}
```
**Fix:** Check that your `Authorization: Bearer vennio_sk_live_...` header is present and the key is active. Verify at [vennio.app/api-keys](https://vennio.app/api-keys).
---
### Publishable key on a server-side endpoint (403)
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "This endpoint requires a secret API key. Publishable keys cannot access this resource. Use a secret key (vennio_sk_live_...) or a user session for server-side operations.",
"request_id": "req_abc123"
}
```
**Fix:** Publishable keys (`vennio_pk_live_*`) reach only the public booking surface. Every other endpoint — creating or managing links, hosts, consents, proposals, bookings, webhooks, and the rest — requires a secret key (`vennio_sk_live_*`) or a user session (JWT).
---
### Validation error (400)
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"customer_email": "Invalid email format",
"start_time": "Must be a future time"
}
}
```
**Fix:** The `errors` object maps field names to specific validation messages. Fix each field before retrying.
---
### Calendar not connected (404)
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "No connected calendar found for this business. Connect a calendar first.",
"request_id": "req_abc123"
}
```
**Fix:** The business account needs at least one connected calendar. Direct the user to connect Google or Microsoft via the OAuth flow. See [Integration Patterns](/guides/integration-patterns#calendar-sync).
---
### Slot conflict (409)
```json
{
"type": "https://api.vennio.app/errors/conflict",
"title": "Conflict",
"status": 409,
"detail": "The requested time slot is no longer available",
"request_id": "req_abc123"
}
```
**Fix:** The slot was booked by someone else between your availability query and booking attempt. Re-query `GET /v1/availability/slots` and present updated options. Do not retry the same time — it will keep failing.
---
### Past time (422)
```json
{
"type": "https://api.vennio.app/errors/unprocessable",
"title": "Unprocessable Entity",
"status": 422,
"detail": "Booking start_time must be in the future",
"request_id": "req_abc123"
}
```
**Fix:** Ensure `start_time` is at least a few minutes in the future. Account for clock skew between your server and the API.
---
### Rate limit exceeded (429)
```json
{
"type": "https://api.vennio.app/errors/rate-limit-exceeded",
"title": "Too Many Requests",
"status": 429,
"detail": "Rate limit exceeded. Retry after 2026-04-11T10:15:00Z",
"retry_after": "2026-04-11T10:15:00Z",
"request_id": "req_abc123"
}
```
**Fix:** Wait until `retry_after` before retrying. Check the rate limit headers on every response:
```
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 2026-04-11T10:15:00.000Z
```
---
## Rate limits
| Plan | Limit |
|---|---|
| Free | 100 requests / hour |
| Premium | 1,000 requests / hour |
| Enterprise | Custom |
Rate limits apply per API key.
---
## Retry logic
Retry `429` and `5xx` errors with exponential backoff. Never retry `4xx` errors — they indicate problems with the request itself.
```javascript
async function callWithRetry(fn, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn()
} catch (err) {
const status = err.status || err.response?.status
if (status === 429 || status >= 500) {
// Honor retry_after if provided
const retryAfter = err.response?.data?.retry_after
const delay = retryAfter
? new Date(retryAfter) - Date.now()
: Math.pow(2, attempt) * 1000 + Math.random() * 500
await new Promise(r => setTimeout(r, Math.max(delay, 0)))
continue
}
throw err // Don't retry 4xx
}
}
throw new Error('Max retries exceeded')
}
```
409 conflicts need special handling
For `409 Conflict`, do not retry the same request — the slot is unavailable. Re-query availability and let the user choose a new time.
---
## Idempotency
For `POST` requests that create resources, pass a unique `Idempotency-Key` header to safely retry on network failures without creating duplicates:
```bash
curl -X POST https://api.vennio.app/v1/bookings \
-H "Authorization: Bearer $VENNIO_API_KEY" \
-H "Idempotency-Key: booking-$(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ ... }'
```
Duplicate requests with the same key return the original response within 24 hours.
---
## Getting help
Include the `request_id` from the error response when contacting support — it lets us trace exactly what happened server-side.
---
# Webhooks
Subscribe to real-time events when bookings are created, updated, or cancelled.
## Overview
Vennio sends HTTP POST requests to your registered URL whenever a subscribed event occurs. Webhook deliveries include a signature header for verification and are retried up to 3 times with exponential backoff.
## Webhook events
| Event | Description |
|---|---|
| `booking.created` | A new booking was made |
| `booking.updated` | A booking was rescheduled |
| `booking.confirmed` | A paid booking was confirmed after successful payment |
| `booking.cancelled` | A booking was cancelled |
| `consent.granted` | A user granted calendar access |
| `consent.revoked` | A user revoked calendar access |
| `proposal.created` | A new multi-party meeting proposal was sent |
| `proposal.countered` | A participant proposed alternative time slots |
| `proposal.accepted` | All required participants accepted the proposal |
| `proposal.rejected` | A required participant declined the proposal |
| `proposal.cancelled` | An accepted proposal was cancelled by a participant |
| `proposal.expired` | A proposal expired without resolution |
| `proposal.resolved` | A poll-mode proposal resolved to a winning slot |
> **Note:** `proposal.consent_revoked` is **not** a webhook event. If a recipient revokes `booking:propose` consent between create and accept, the proposal transitions to `expired` and the originator is notified by email — no webhook is fired.
## Create a webhook
```bash
curl -X POST https://api.vennio.app/v1/webhooks \
-H "Authorization: Bearer vennio_sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/vennio",
"events": ["booking.created", "booking.cancelled"],
"description": "My booking notifications"
}'
```
```json title="Response"
{
"id": "wh_abc123",
"url": "https://your-app.com/webhooks/vennio",
"events": ["booking.created", "booking.cancelled"],
"secret": "whsec_xxxxxxxxxxxxxxxxxxxx",
"created_at": "2026-01-20T10:00:00Z"
}
```
Save your webhook secret
The webhook signing secret is only shown once at creation. Use it to verify incoming payloads.
## Verify signatures
Every webhook delivery includes a `X-Webhook-Signature` header containing an HMAC-SHA256 signature of the raw request body. Always verify this before processing.
```javascript
import crypto from 'crypto'
function verifyWebhookSignature(body, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(body, 'utf8')
.digest('hex')
if (signature.length !== expected.length) return false
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
)
}
// Express handler
app.post('/webhooks/vennio', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-webhook-signature']
if (!verifyWebhookSignature(req.body, sig, process.env.WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' })
}
const event = JSON.parse(req.body)
console.log('Event received:', event.type)
// Process event...
res.json({ received: true })
})
```
## Retry schedule
If your endpoint returns a non-2xx status or times out, Vennio retries with backoff:
| Attempt | Delay |
|---|---|
| 1st retry | 1 minute |
| 2nd retry | 5 minutes |
| 3rd retry | 30 minutes |
| Failed | Marked as failed after 4 total attempts |
View delivery history at `GET /v1/webhooks/{id}/deliveries`.
## Event payloads
All events follow a consistent envelope:
```json
{
"id": "evt_abc123",
"type": "booking.created",
"created_at": "2026-01-20T14:30:00Z",
"data": {
"booking": {
"id": "booking_xyz789",
"status": "confirmed",
"start_time": "2026-01-21T09:00:00Z",
"end_time": "2026-01-21T09:30:00Z",
"customer_email": "customer@example.com",
"customer_name": "Jane Doe"
}
}
}
```
### `proposal.resolved`
Fires when a poll-mode proposal resolves to a winning slot. Same shape regardless of which trigger resolved it (all-voted, organiser close, reaper expiry). Delivered post-commit, after `proposals.status='accepted'` is durable.
Only the organiser's webhook subscribers receive this event. Voters without Vennio accounts — including all open-poll voters and any email-keyed named voters — are unreachable via webhooks by design. Fan-out to voters is the builder's surface: see [Poll-Mode Proposals](/guides/poll-mode-proposals) for the Model 1 boundary.
```json
{
"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
}
}
```
`poll_variant` is `"named"` or `"open"`. `tie_count` is the number of slots that finished on the winning vote count — `1` means a clean winner; `2+` means earliest `start_time` broke the tie.
For paid bookings, the `booking.confirmed` event includes payment details:
```json
{
"id": "evt_def456",
"type": "booking.confirmed",
"created_at": "2026-03-15T10:05:00Z",
"data": {
"booking": {
"id": "booking_xyz789",
"status": "confirmed",
"start_time": "2026-03-15T10:00:00Z",
"end_time": "2026-03-15T10:30:00Z",
"customer_email": "customer@example.com",
"customer_name": "Jane Doe",
"payment_amount": 5000,
"payment_currency": "usd"
}
}
}
```
---
# Paid Bookings
Collect payments via Stripe before confirming bookings on your Venn Links.
## Overview
Vennio uses **Stripe Connect** (Express accounts) with direct charges to let your users accept payments on their booking links. When a Venn Link has a price, customers are redirected to Stripe Checkout before the booking is confirmed. Once payment succeeds, Vennio automatically confirms the booking, creates the calendar event, and sends confirmation emails.
## Setup
Three steps to enable paid bookings for a user:
### 1. Connect Stripe
Create a Stripe Express connected account for the user:
```bash
curl -X POST https://api.vennio.app/v1/stripe/connect \
-H "Authorization: Bearer vennio_sk_live_YOUR_KEY"
```
```json title="Response"
{
"stripe_account_id": "acct_1234567890",
"onboarding_complete": false,
"charges_enabled": false,
"ready": false
}
```
This endpoint is idempotent — calling it again returns the existing account.
### 2. Complete onboarding
Generate an account session for the embedded onboarding UI. Use the `client_secret` with Stripe's [Connect embedded components](https://docs.stripe.com/connect/get-started-connect-embedded-components):
```bash
curl -X POST https://api.vennio.app/v1/stripe/account-session \
-H "Authorization: Bearer vennio_sk_live_YOUR_KEY"
```
```json title="Response"
{
"client_secret": "acss_xxxxxxxxxxxxx"
}
```
### 3. Verify readiness
Check that onboarding is complete and the account can accept charges:
```bash
curl https://api.vennio.app/v1/stripe/account \
-H "Authorization: Bearer vennio_sk_live_YOUR_KEY"
```
```json title="Response"
{
"stripe_account_id": "acct_1234567890",
"charges_enabled": true,
"payouts_enabled": true,
"details_submitted": true,
"onboarding_complete": true,
"ready": true
}
```
ready flag
The `ready` field is `true` when both `charges_enabled` and `onboarding_complete` are true. Only accounts with `ready: true` can accept paid bookings.
## Add pricing to a Venn Link
Set `price_amount` (in cents) and `price_currency` (ISO 4217) on a Venn Link to make it paid:
```bash
curl -X POST https://api.vennio.app/v1/venn-links \
-H "Authorization: Bearer vennio_sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Strategy Session",
"event_type_id": "YOUR_EVENT_TYPE_ID",
"price_amount": 5000,
"price_currency": "usd"
}'
```
| Value | Meaning |
|---|---|
| `null` | Inherit pricing from the event type (default) |
| `0` | Explicitly free — no payment required |
| `> 0` | Paid — amount in cents (e.g. 5000 = $50.00) |
## Paid booking flow
When a customer books a paid Venn Link, the flow is:
1. Customer submits booking via `POST /v1/venn-links/:shortCode/book`
2. Vennio creates the booking with status `pending_payment`
3. Vennio creates a Stripe Checkout session and returns a `checkout_url`
4. Customer completes payment on Stripe Checkout
5. Stripe sends `checkout.session.completed` webhook to Vennio
6. Vennio confirms the booking, creates the calendar event, and sends confirmation emails
```bash
# Book a paid Venn Link
curl -X POST https://api.vennio.app/v1/venn-links/abc123/book \
-H "Content-Type: application/json" \
-d '{
"customer_email": "customer@example.com",
"customer_name": "Jane Doe",
"start_time": "2026-03-15T10:00:00Z",
"end_time": "2026-03-15T10:30:00Z"
}'
```
```json title="Response (pending payment)"
{
"success": true,
"booking": {
"id": "booking_xyz789",
"status": "pending_payment",
"start_time": "2026-03-15T10:00:00Z",
"end_time": "2026-03-15T10:30:00Z",
"checkout_url": "https://checkout.stripe.com/c/pay/cs_live_..."
},
"message": "Booking created. Redirect customer to checkout_url to complete payment."
}
```
Redirect required
The booking is not confirmed until the customer completes payment. Redirect them to the `checkout_url` immediately after receiving the response.
You can optionally pass `success_url` and `cancel_url` to override the default Stripe Checkout redirect destinations.
## Booking questions
Define custom form fields on an event type or Venn Link. Customers submit answers when booking, and answers are included in confirmation emails.
### Define questions
Add a `booking_questions` array when creating or updating an event type or Venn Link:
```json
{
"booking_questions": [
{
"type": "text",
"label": "Company name",
"required": true,
"placeholder": "Acme Corp"
},
{
"type": "select",
"label": "Meeting topic",
"required": true,
"options": ["Product demo", "Technical support", "Partnership"]
},
{
"type": "textarea",
"label": "Anything else?",
"required": false
}
]
}
```
Supported question types:
| Type | Description | Extra fields |
|---|---|---|
| `text` | Single-line text input | `placeholder` |
| `textarea` | Multi-line text input | `placeholder` |
| `select` | Dropdown selection | `options` (required, 1-50 strings) |
| `checkbox` | Boolean checkbox | `default_value` |
| `phone` | Phone number input | `placeholder` |
| `email` | Email address input | `placeholder` |
Each question receives a stable `id` (e.g. `q_a3f8b2c1`) generated by the server. Use these IDs to key answers when booking.
### Submit answers
Pass an `answers` object keyed by question ID when booking:
```json
{
"customer_email": "customer@example.com",
"customer_name": "Jane Doe",
"start_time": "2026-03-15T10:00:00Z",
"end_time": "2026-03-15T10:30:00Z",
"answers": {
"q_a3f8b2c1": "Acme Corp",
"q_b7e2d4f0": "Product demo",
"q_c9f1a3e5": "Looking forward to learning more!"
}
}
```
## Testing
Use `demo_mode: true` on booking requests to skip calendar events, emails, and webhooks. For payment testing, use Stripe test mode API keys — Stripe provides [test card numbers](https://docs.stripe.com/testing) for simulating successful and failed payments.
## Limitations
- **One Stripe account per user** — each user can connect a single Stripe Express account
- **No recurring paid bookings** — recurring booking series cannot have payment attached
- **Direct charges only** — payments go directly to the connected account (no platform fees yet)
---
# Pricing & Billing
## API billing (developer plans)
Vennio has a subscription billing system for API access. Plans include a free tier (1,000 bookings/mo) and paid plans `indie` (£29/mo, 1,000 bookings, 5 calendars) and `builder` (£99/mo, 10,000 bookings, unlimited calendars). A `scale` tier (£299/mo, 50,000 bookings) is available via Contact Us.
### Check your current plan
```bash
curl https://api.vennio.app/v1/billing/subscription \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
Response shape:
```json
{
"plan": "indie",
"status": "active",
"current_period_end": "2026-05-01T00:00:00Z",
"usage": {
"calls_this_month": 1240
}
}
```
`plan: null` indicates the free tier.
### Upgrade your plan
```bash
curl -X POST https://api.vennio.app/v1/billing/checkout \
-H "Authorization: Bearer $VENNIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "plan": "indie" }'
```
Returns a Stripe Checkout URL. Redirect the user to that URL to complete payment.
### Manage your subscription
```bash
curl -X POST https://api.vennio.app/v1/billing/portal \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
Returns a Stripe Customer Portal URL where the user can update payment details, change plan, or cancel.
For specific plan pricing and feature limits, see [vennio.app/pricing](https://vennio.app/pricing).
## Marketplace payments
Vennio also supports paid bookings via Stripe Connect. Businesses can charge customers at booking time by setting a `price_amount` on a Venn Link.
Vennio collects a configurable platform fee (`platform_fee_bps`, specified in basis points) via Stripe Connect's `application_fee_amount` mechanism. This is separate from API subscription billing.
---
# Vennio for Agents (MCP)
Connect Claude, GPT-4, or any MCP-compatible agent to Vennio's scheduling primitives. Your AI can find mutual availability, create bookings, and negotiate meeting times — all with a single API key.
## What is MCP?
The [Model Context Protocol](https://modelcontextprotocol.io) (MCP) is an open standard for connecting AI models to external tools and data sources. It lets you expose structured functions — like "find a free time slot" or "create a booking" — as tools that any MCP-compatible AI agent can call autonomously.
Vennio exposes 24 scheduling tools over MCP. This means an AI agent can handle the full scheduling workflow — check availability, propose times, handle counters, confirm the booking — without you writing a custom integration layer.
## Prerequisites
1. **A Vennio API key** — Go to Settings → API Keys on [vennio.app](https://vennio.app) and create a secret key (`vennio_sk_live_*`).
2. **Connected calendar** — The authenticated user must have a Google Calendar or Microsoft 365 account connected. The MCP tools operate on their behalf.
3. **Consent grants (for multi-party scheduling)** — Tools like `find_mutual_availability` require each person to have granted calendar access consent. Use the [Consents API](/api/consents) or the Vennio app to set these up before running multi-party queries.
Consent is required for accessing other people's calendars
Vennio is consent-scoped by design. An agent can only read a person's availability if that person has granted an active consent. Use `check_consent` to verify before calling `find_mutual_availability`. If you don't yet have access, `request_consent` creates a pending request the other party can accept with `respond_to_consent_request` — so two agents with no existing grant can bootstrap a scheduling relationship entirely in-protocol.
## Connect to Vennio
The Vennio MCP server runs at `https://api.vennio.app/mcp` over Streamable HTTP (stateless JSON-RPC). Each request is authenticated with your API key.
### Claude Desktop
Add Vennio to your `claude_desktop_config.json` file (found at `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
```json title="claude_desktop_config.json"
{
"mcpServers": {
"vennio": {
"type": "http",
"url": "https://api.vennio.app/mcp",
"headers": {
"Authorization": "Bearer vennio_sk_live_YOUR_KEY"
}
}
}
}
```
Restart Claude Desktop. You'll see "vennio" listed in the MCP tools panel.
### Programmatic (Node.js)
For agents and automation, connect directly using the MCP SDK:
```bash
npm install @modelcontextprotocol/sdk
```
```javascript title="agent.js"
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
const client = new Client({ name: 'my-agent', version: '1.0.0' })
const transport = new StreamableHTTPClientTransport(
new URL('https://api.vennio.app/mcp'),
{
requestInit: {
headers: { Authorization: `Bearer ${process.env.VENNIO_API_KEY}` },
},
}
)
await client.connect(transport)
// List available tools
const { tools } = await client.listTools()
console.log(tools.map(t => t.name))
// → ['find_availability', 'find_mutual_availability', 'create_booking', ...]
```
## Tool walkthrough
Three tools cover the core scheduling loop. All others are listed in the [full table below](#all-24-tools).
### find_mutual_availability
Find time slots where all specified people are free. This is Vennio's signature feature — consent-scoped mutual scheduling across multiple calendars.
```javascript title="Find a slot for two people"
const result = await client.callTool({
name: 'find_mutual_availability',
arguments: {
principal_ids: [
'USER_UUID_1',
'USER_UUID_2',
],
duration_minutes: 60,
from: '2026-04-01T00:00:00Z',
to: '2026-04-07T23:59:59Z',
timezone: 'America/New_York',
limit: 5,
},
})
```
```json title="Response"
{
"mutual_slots": [
{
"start": "2026-04-02T14:00:00-05:00",
"end": "2026-04-02T15:00:00-05:00",
"duration_minutes": 60,
"principals_free": ["USER_UUID_1", "USER_UUID_2"]
},
{
"start": "2026-04-03T10:00:00-05:00",
"end": "2026-04-03T11:00:00-05:00",
"duration_minutes": 60,
"principals_free": ["USER_UUID_1", "USER_UUID_2"]
}
],
"total": 2
}
```
Consent required
Each principal in `principal_ids` must have an active consent granting the authenticated user access to their availability. Call `check_consent` first if you're not sure.
### create_booking
Create a confirmed booking directly — no proposal round trip required. Use this when you already know the time (e.g., the user just picked a slot from a widget). The booking metadata records the AI agent's identity for the audit trail.
```javascript title="Create a booking"
const result = await client.callTool({
name: 'create_booking',
arguments: {
business_id: 'BUSINESS_UUID',
customer_email: 'customer@example.com',
customer_name: 'Jane Doe',
start_time: '2026-04-02T14:00:00Z',
end_time: '2026-04-02T15:00:00Z',
notes: 'Initial consultation',
agent_name: 'My Scheduling Agent',
},
})
```
```json title="Response"
{
"booking_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "confirmed",
"business_id": "BUSINESS_UUID",
"customer_name": "Jane Doe",
"customer_email": "customer@example.com",
"start_time": "2026-04-02T14:00:00Z",
"end_time": "2026-04-02T15:00:00Z",
"created_at": "2026-03-28T22:00:00Z"
}
```
### propose_meeting
Create a multi-party meeting proposal with one or more time slots. Participants can accept, counter with alternative times, or reject. This enables the full negotiate-until-confirmed loop that agents like the [calendar agent example](#full-agent-example) implement.
```javascript title="Propose a meeting"
const result = await client.callTool({
name: 'propose_meeting',
arguments: {
participant_ids: ['USER_UUID_1', 'USER_UUID_2'],
slots: [
{
start: '2026-04-02T14:00:00Z',
end: '2026-04-02T15:00:00Z',
preference: 0,
},
{
start: '2026-04-03T10:00:00Z',
end: '2026-04-03T11:00:00Z',
preference: 1,
},
],
message: 'Happy to meet at either of these times.',
expires_in_hours: 48,
},
})
```
When a participant responds, poll `get_proposal` or listen for a `proposal.responded` webhook. If they counter, call `propose_meeting` again with the revised slots. If they accept, call `create_booking` with the accepted slot.
## All 24 tools
| Tool | Description |
|---|---|
| `find_availability` | Bookable slots for a business, accounting for calendar events and existing bookings |
| `find_mutual_availability` | Slots where 2–5 people are all free (requires consent from each) |
| `create_booking` | Confirm a booking directly — creates calendar event, sends emails, fires webhooks |
| `get_booking` | Retrieve a booking by ID |
| `cancel_booking` | Cancel a confirmed booking, fires cancellation webhooks |
| `propose_meeting` | Create a multi-party meeting proposal with proposed time slots |
| `respond_to_proposal` | Accept, counter, or reject a proposal on behalf of a participant |
| `get_proposal` | Retrieve a proposal and optionally the full negotiation thread |
| `check_consent` | Verify you have consent to access a principal's calendar data |
| `get_network` | Snapshot of your connection network — counts, types, recent contacts |
| `list_connections` | List network connections with filtering by type, status, or search |
| `find_mutual_connections` | Find people connected to all specified principals (must include yourself) |
| `get_me` | Get the authenticated user's identity and connected calendar status |
| `list_event_types` | List bookable event types for a business |
| `list_bookings` | List bookings with filtering by status, date range, or business |
| `reschedule_booking` | Move a confirmed booking to a new time slot |
| `register_webhook` | Register a webhook endpoint to receive booking and proposal events |
| `grant_consent` | Grant another principal access to your availability data |
| `request_consent` | Request access to another principal's data — creates a pending consent request they can accept or reject |
| `respond_to_consent_request` | Accept or reject a pending consent request addressed to you (accept creates the consent) |
| `list_consent_requests` | List consent requests involving you — incoming and outgoing, filterable by status |
| `withdraw_consent_request` | Withdraw a pending consent request you created |
| `create_schedule` | Create a weekly availability schedule for a business |
| `create_event_type` | Create a bookable event type (duration, title, description) |
| `firecrawl_scrape` | Scrape a URL for context (requires Firecrawl integration) |
| `firecrawl_crawl` | Crawl a site for structured data (requires Firecrawl integration) |
| `firecrawl_search` | Search the web for context before a meeting (requires Firecrawl integration) |
## Full agent example
The `examples/calendar-agent/` directory contains a complete working agent that runs the full scheduling loop autonomously:
1. **DISCOVER** — Fetches `/.well-known/ai-agent` to learn capabilities
2. **CONSENT** — Verifies the customer has granted availability access
3. **AVAILABLE** — Queries the customer's calendar for free blocks
4. **PROPOSE** — Selects optimal slots and creates a proposal
5. **NEGOTIATE** — Handles accept / counter / reject (up to 3 rounds)
6. **CONFIRM** — Locks the booking and fires webhooks
```bash title="Run the calendar agent"
# Clone the repo, then:
cd examples/calendar-agent
VENNIO_API_KEY=vennio_sk_live_YOUR_KEY node agent.js BUSINESS_ID CUSTOMER_ID
# Or try it with mock data (no API key needed)
node agent.js --demo --verbose
```
The agent handles counter-proposals automatically — if the customer suggests a different time, the agent re-proposes using those slots. After three rounds without agreement, it escalates to a human operator.
Using Vennio with Claude's built-in tool use
If you're building with the Anthropic API directly (not Claude Desktop), pass the Vennio MCP tool definitions as `tools` in your API call, then handle `tool_use` blocks by calling the corresponding Vennio REST endpoints. See the [JavaScript SDK](/sdks/javascript) for the equivalent REST methods.
---
# Firecrawl + Vennio MCP
Firecrawl and Vennio are two separate MCP servers that work well together. Firecrawl gives agents web intelligence — scrape, search, crawl. Vennio gives agents scheduling primitives — availability, bookings, proposals. Run both and an agent can go from *scrape the web* to *book the time* in one prompt.
Two separate MCP servers
Firecrawl is not embedded in Vennio's MCP server. They are independent servers you configure side-by-side. Vennio exposes 12 scheduling tools. Firecrawl exposes web tools. Your agent calls whichever it needs.
## What each server provides
### Vennio MCP tools (12 total)
Vennio exposes scheduling primitives at `https://api.vennio.app/mcp`. Key tools:
| Tool | What it does |
|---|---|
| `find_availability` | Bookable slots for a business, accounting for existing calendar events and bookings |
| `find_mutual_availability` | Slots where 2–5 people are all free (requires consent from each) |
| `create_booking` | Confirm a booking directly — creates calendar event, sends emails, fires webhooks |
| `propose_meeting` | Create a multi-party meeting proposal with proposed time slots |
| `respond_to_proposal` | Accept, counter, or reject a proposal on behalf of a participant |
| `get_proposal` | Retrieve a proposal and optionally the full negotiation thread |
| `get_booking` | Retrieve a booking by ID |
| `cancel_booking` | Cancel a confirmed booking, fires cancellation webhooks |
| `check_consent` | Verify you have consent to access a principal's calendar data |
| `get_network` | Snapshot of your connection network — counts, types, recent contacts |
| `list_connections` | List network connections with filtering by type, status, or search |
| `find_mutual_connections` | Find people connected to all specified principals |
See the [Vennio for Agents (MCP)](/guides/mcp-integration) guide for full parameter docs and examples for each tool.
### Firecrawl MCP tools
Firecrawl provides web data extraction tools. The three you'll use most alongside Vennio:
| Tool | What it does |
|---|---|
| `firecrawl_scrape` | Scrape a URL and return clean markdown or structured data |
| `firecrawl_search` | Search the web and return ranked results with scraped content |
| `firecrawl_crawl` | Crawl a site and return content across multiple pages |
Full Firecrawl tool docs are at [docs.firecrawl.dev/mcp](https://docs.firecrawl.dev/mcp).
## Configure both servers
### Claude Desktop
Add both servers to `~/Library/Application Support/Claude/claude_desktop_config.json`. Firecrawl can run locally via `npx` or connect to their hosted endpoint.
**Option A — local Firecrawl (npx):**
```json title="claude_desktop_config.json"
{
"mcpServers": {
"vennio": {
"type": "http",
"url": "https://api.vennio.app/mcp",
"headers": {
"Authorization": "Bearer vennio_sk_live_YOUR_KEY"
}
},
"firecrawl": {
"command": "npx",
"args": ["-y", "firecrawl-mcp"],
"env": {
"FIRECRAWL_API_KEY": "fc-YOUR_FIRECRAWL_KEY"
}
}
}
}
```
**Option B — hosted Firecrawl endpoint:**
```json title="claude_desktop_config.json"
{
"mcpServers": {
"vennio": {
"type": "http",
"url": "https://api.vennio.app/mcp",
"headers": {
"Authorization": "Bearer vennio_sk_live_YOUR_KEY"
}
},
"firecrawl": {
"type": "http",
"url": "https://mcp.firecrawl.dev/YOUR_FIRECRAWL_KEY/v2/mcp"
}
}
}
```
Restart Claude Desktop after saving. Both "vennio" and "firecrawl" will appear in the MCP tools panel.
### Cursor
In Cursor, go to **Settings → Cursor Settings → MCP** and add both servers under `mcpServers` using the same config format as above. Cursor reads the same JSON structure.
## Example workflows
Each workflow below shows the agent prompt and the sequence of MCP tool calls the agent makes to complete the task.
### Workflow 1: Pricing alert review meeting
An agent monitors a competitor's pricing page and books a review meeting when it detects a change.
```text title="Agent prompt"
Check acme.com/pricing. If the pricing has changed since last week,
book a 30-minute review meeting with Sarah and Marcus for this week.
```
**Tool sequence:**
1. `firecrawl_scrape` — fetch `acme.com/pricing` and extract structured pricing data
2. Compare against a stored snapshot (or ask the user to confirm a change was detected)
3. `find_mutual_availability` — find slots where Sarah and Marcus are both free this week
4. `create_booking` — book the first available 30-minute slot
```javascript title="Tool calls (abridged)"
// 1. Scrape competitor pricing
const page = await client.callTool({
name: 'firecrawl_scrape',
arguments: {
url: 'https://acme.com/pricing',
formats: ['markdown'],
},
})
// pricing change detected — find a time for Sarah and Marcus
// 2. Find mutual availability
const slots = await client.callTool({
name: 'find_mutual_availability',
arguments: {
principal_ids: ['SARAH_UUID', 'MARCUS_UUID'],
duration_minutes: 30,
from: '2026-04-07T00:00:00Z',
to: '2026-04-11T23:59:59Z',
timezone: 'America/New_York',
limit: 3,
},
})
// 3. Book the first available slot
const booking = await client.callTool({
name: 'create_booking',
arguments: {
business_id: 'BUSINESS_UUID',
customer_email: 'sarah@example.com',
customer_name: 'Sarah Chen',
start_time: slots.content[0].mutual_slots[0].start,
end_time: slots.content[0].mutual_slots[0].end,
notes: 'Acme pricing change detected — review required',
agent_name: 'Pricing Monitor Agent',
},
})
```
### Workflow 2: Search weekly digest booking
An agent searches for relevant news and books a team digest meeting to discuss it.
```text title="Agent prompt"
Search Hacker News for posts about scheduling APIs this week.
Book a 30-minute team digest for Friday afternoon.
```
**Tool sequence:**
1. `firecrawl_search` — search for recent HN posts on scheduling APIs
2. `find_availability` — find open 30-minute slots on Friday afternoon
3. `create_booking` — book the slot with a summary of top posts in the notes
```javascript title="Tool calls (abridged)"
// 1. Search for relevant posts
const results = await client.callTool({
name: 'firecrawl_search',
arguments: {
query: 'scheduling API site:news.ycombinator.com',
limit: 5,
scrapeOptions: { formats: ['markdown'] },
},
})
// extract top post titles for the booking notes
const summary = results.content[0].results
.map(r => `- ${r.title}`)
.join('\n')
// 2. Find availability on Friday afternoon
const availability = await client.callTool({
name: 'find_availability',
arguments: {
business_id: 'BUSINESS_UUID',
duration_minutes: 30,
from: '2026-04-11T13:00:00Z',
to: '2026-04-11T18:00:00Z',
timezone: 'America/New_York',
},
})
// 3. Book the first available slot
const booking = await client.callTool({
name: 'create_booking',
arguments: {
business_id: 'BUSINESS_UUID',
customer_email: 'team@example.com',
customer_name: 'Team Digest',
start_time: availability.content[0].slots[0].start,
end_time: availability.content[0].slots[0].end,
notes: `Top HN posts this week:\n${summary}`,
agent_name: 'Digest Scheduler',
},
})
```
### Workflow 3: Enrich a booking with company context
When a demo is booked via webhook, an agent scrapes the prospect's company website and adds a briefing to the booking notes.
```text title="Agent prompt / webhook trigger"
A new demo booking just came in from jane@acmecorp.com.
Scrape acmecorp.com and add a company briefing to the booking notes.
```
**Tool sequence:**
1. Webhook fires `booking.created` — handler reads `customer_email` and derives the company domain
2. `firecrawl_scrape` — scrape the company homepage for context
3. `get_booking` — fetch the existing booking
4. Update the booking notes via the REST API with the enriched briefing
```javascript title="Webhook handler (Node.js)"
import express from 'express'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
const app = express()
app.use(express.json())
const vennio = new Client({ name: 'demo-enricher', version: '1.0.0' })
const firecrawl = new Client({ name: 'demo-enricher', version: '1.0.0' })
await vennio.connect(new StreamableHTTPClientTransport(
new URL('https://api.vennio.app/mcp'),
{ requestInit: { headers: { Authorization: `Bearer ${process.env.VENNIO_API_KEY}` } } }
))
await firecrawl.connect(new StreamableHTTPClientTransport(
new URL(`https://mcp.firecrawl.dev/${process.env.FIRECRAWL_API_KEY}/v2/mcp`)
))
app.post('/webhooks/vennio', async (req, res) => {
const { event, data } = req.body
if (event !== 'booking.created') return res.sendStatus(200)
const { id: booking_id, customer_email } = data.booking
const domain = customer_email.split('@')[1]
// Scrape the company site
const page = await firecrawl.callTool({
name: 'firecrawl_scrape',
arguments: { url: `https://${domain}`, formats: ['markdown'] },
})
const briefing = `Company briefing (scraped ${new Date().toISOString().slice(0,10)}):\n`
+ page.content[0].markdown.slice(0, 500)
// Update the booking notes via Vennio REST API
await fetch(`https://api.vennio.app/v1/bookings/${booking_id}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${process.env.VENNIO_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ notes: briefing }),
})
res.sendStatus(200)
})
app.listen(3000)
```
Webhook setup
Register your webhook endpoint in the Vennio dashboard under Settings → Webhooks. Subscribe to the `booking.created` event. See the [Webhooks guide](/overview/webhooks) for signature verification.
## Get started
1. **Vennio API key** — Create a secret key at [vennio.app/api-keys](https://vennio.app/api-keys) (`vennio_sk_live_*`)
2. **Firecrawl API key** — Sign up at [firecrawl.dev](https://firecrawl.dev) and copy your API key from the dashboard
3. **Add both servers** to your MCP client config as shown [above](#configure-both-servers)
4. **Review Vennio MCP docs** — full tool parameter reference is in the [Vennio for Agents (MCP)](/guides/mcp-integration) guide
---
# How Vennio is different
## The core difference
Most scheduling tools are **supply-side**: they publish your calendar and let people book time with you. Vennio is **demand-side**: it lets you schedule on behalf of others — with their explicit consent.
This isn't a UX distinction. It's an architectural one. Vennio is scheduling infrastructure for developers and AI agents that need to coordinate time across multiple parties, not just accept inbound bookings.
## Demand-side vs supply-side
**Supply-side scheduling** (Calendly, Cal.com, etc.) works like this:
1. You connect your calendar
2. You publish a booking link
3. Someone visits the link and picks a time that works for you
**Demand-side scheduling** (Vennio) works like this:
1. Multiple parties connect their calendars and grant consent
2. You (or an AI agent) query mutual availability across all parties
3. You propose times, negotiate, and confirm — programmatically
## Consent at the API level
Vennio treats consent as a first-class API primitive. Before any scheduling action that touches another person's calendar, that person must have granted an explicit consent token. This is enforced at the API level — not just in the UI.
This makes Vennio suitable for:
- **AI agents** that schedule meetings on behalf of users
- **B2B platforms** that coordinate time between their customers and vendors
- **Enterprise tools** that need GDPR-compliant calendar access
## Comparison table
| Feature |
Vennio |
Calendly |
Cal.com |
Cronofy |
| Demand-side scheduling |
✓ |
✗ |
✗ |
✗ |
| Consent-based booking |
✓ |
✗ |
✗ |
✗ |
| REST API |
✓ |
✓ |
✓ |
✓ |
| MCP / AI-native tools |
✓ |
✗ |
✗ |
✗ |
| Multi-party coordination |
✓ |
Partial |
Partial |
Partial |
## When to use Vennio
Use Vennio when:
- You need to **schedule on behalf of users**, not just accept bookings for yourself
- You're building an **AI agent** that coordinates meetings autonomously
- You need **multi-party availability** — finding time when 2–5 people are all free
- You run **many bookable hosts under one account** — coaches, agents, practitioners — each individually booked and attributed ([Multi-Host Accounts](/guides/multi-host-accounts))
- You need **consent management** built into the scheduling layer
- You want **composable primitives** (availability, bookings, proposals, venn-links) rather than an opinionated SaaS product
Use a supply-side tool (Calendly, Cal.com) when you just need a booking page for yourself.
---
# Team Scheduling
Teams let multiple Vennio users share availability. When you query a team's availability, Vennio finds slots where all schedulable members are simultaneously free.
> **Teams vs hosts.** Use a *team* to find one slot where a group is *all* free (one meeting, many attendees). To run a roster of *independently*-bookable people under one account — each booked separately, on their own calendar — use [Multi-Host Accounts](/guides/multi-host-accounts) instead.
## Endpoints
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/teams | Create a team |
| GET | /v1/teams | List my teams |
| GET | /v1/teams/:id | Get team + members |
| PATCH | /v1/teams/:id | Update name/description (owner only) |
| DELETE | /v1/teams/:id | Delete team (owner only) |
| POST | /v1/teams/:id/members | Add a member |
| DELETE | /v1/teams/:id/members/:memberId | Remove a member (owner only) |
| GET | /v1/teams/:id/availability | Get mutual free slots |
| POST | /v1/teams/:id/proposals | Create a scheduling proposal |
## Create a team
```bash
curl -X POST https://api.vennio.app/v1/teams \
-H "Authorization: Bearer $VENNIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Engineering Squad",
"description": "Weekly syncs and interviews"
}'
```
## Get mutual availability
```bash
curl "https://api.vennio.app/v1/teams/team_abc123/availability?duration_minutes=30&from=2026-04-07T09:00:00Z&to=2026-04-11T17:00:00Z&timezone=Europe/London&limit=10" \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
### Availability query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| `duration_minutes` | integer | 30 | Duration of the desired slot |
| `from` | ISO datetime | — | Required. Window start |
| `to` | ISO datetime | — | Required. Window end |
| `timezone` | IANA timezone | UTC | Timezone for slot grouping |
| `limit` | integer (1–50) | 10 | Maximum slots to return |
## Create a proposal
A proposal shares a set of candidate slots with invitees and collects their preference:
```bash
curl -X POST https://api.vennio.app/v1/teams/team_abc123/proposals \
-H "Authorization: Bearer $VENNIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Q2 Planning Session",
"slots": [
{ "start": "2026-04-08T10:00:00Z", "end": "2026-04-08T10:30:00Z" },
{ "start": "2026-04-09T14:00:00Z", "end": "2026-04-09T14:30:00Z" }
]
}'
```
## Member types
| Type | Description |
|---|---|
| `vennio_user` | A registered Vennio user. Their calendar is checked for availability. |
| `external_consent` | An external party who must consent before their availability is considered. |
| `email_only` | Added by email address only. No calendar access — skipped in availability calculations. |
## Gotchas
- **Cannot remove the team owner.** The owner must transfer ownership or delete the team.
- **If all members are `email_only`, availability calls will fail** — there are no calendars to query.
- **`email_only` members are skipped** in mutual availability calculations. They can still receive proposals.
---
# 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:
- recipients don't have Vennio accounts,
- the set of recipients is open-ended (a link in a group chat, an SMS blast, a public page),
- you want explicit majority resolution across more than two parties,
- you're a business asking a customer for their preference rather than offering a single fixed time.
## 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).
```bash title="Named poll"
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" }
]
}'
```
```bash title="Open poll"
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](#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/` on the portal host (`https://app.vennio.app/p/` 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`).
```bash title="Open-poll vote"
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:
- Both `voter_name` and `voter_email` are optional. Omit both and the row is recorded as `Anonymous`.
- Supplying `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.
### 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 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.
```bash title="Organiser close"
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.
```json title="proposal.resolved payload"
{
"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.
- Returns `409 poll_not_resolved` until the poll resolves.
- Returns `410 token_expired` after the token's expiry.
- Returns `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.
## 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:
- Webhooks reach the **organiser's** subscribers only. Voter fan-out is yours to design.
- Reminder nudges to non-voters are yours to design. The API can tell you who hasn't voted on a named poll (via the participants list); it won't poke them.
- Open-poll voter identity is whatever you decide it is. Treat `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.
## 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.
- **Variant:** open poll. The invitee set is the chat membership, which the app doesn't have a clean handle on.
- **Distribution:** the app posts the `magic_link_url` directly into the chat thread.
- **Voter identity:** anonymous is fine. Some apps ask for a display name on the vote page and pass it as `voter_name`; that's a UX call, not a contract change.
- **Resolution:** organiser close. The organiser sees a live tally in the app and taps "Lock it in" when they're satisfied.
- **Confirm:** `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`.
## Example: demand-side B2B2C
A clinic offering a re-book slot to a patient. The business is the organiser; the patient is the voter.
- **Variant:** named poll with one invitee — the patient — if the clinic has the patient's email and wants a single-use bound token. Open poll if the clinic sends the link by SMS and wants the patient to be able to tap it from any device without re-auth.
- **Distribution:** SMS or email, whichever the clinic uses for appointment comms. The API mints the link; the clinic's existing patient-comms system delivers it.
- **Voter identity:** the bound participant row for named, or `voter_email` (patient's email on file) prefilled by the clinic's web form for open.
- **Resolution:** all-voted (one invitee, one vote, named) or organiser close (open). Expiry as a backstop.
- **Confirm:** `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.
## Provisional contracts
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.
---
# Recurring Bookings
A recurring booking creates a series of bookings at a fixed frequency. Each occurrence is a separate booking record tracked under a `booking_series` parent object.
## Endpoints
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/bookings | Create a booking — include a `recurrence` object to create a series |
| GET | /v1/booking-series | List all series |
| GET | /v1/booking-series/:id | Get series + all occurrences |
| GET | /v1/bookings?series_id= | Filter individual bookings by series |
## Create a recurring booking
Include a `recurrence` object in the standard booking creation request:
```bash
curl -X POST https://api.vennio.app/v1/bookings \
-H "Authorization: Bearer $VENNIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"business_id": "your-business-id",
"customer_email": "customer@example.com",
"customer_name": "Jane Doe",
"start_time": "2026-04-07T10:00:00Z",
"end_time": "2026-04-07T10:30:00Z",
"recurrence": {
"frequency": "weekly",
"count": 8,
"timezone": "America/New_York"
}
}'
```
### Recurrence object fields
| Field | Type | Description |
|---|---|---|
| `frequency` | `weekly \| biweekly \| monthly` | How often the booking repeats |
| `count` | integer | Number of occurrences to create |
| `until_date` | ISO date | Create occurrences up to (and including) this date |
| `timezone` | IANA timezone | Wall-clock timezone for recurrence expansion |
Provide `count` **or** `until_date` — not both.
## Series response
The response includes the `booking_series` object alongside the created bookings:
```json
{
"booking_series": {
"id": "series_abc123",
"frequency": "weekly",
"created_count": 8,
"skipped_count": 0
},
"bookings": [...]
}
```
- `created_count` — number of occurrences successfully created
- `skipped_count` — number of occurrences skipped due to conflicts
## Cancellation
Cancelling a series cancels all **future confirmed** occurrences and fires a `booking_series.cancelled` webhook. Past occurrences are unaffected.
## DST handling
The `timezone` in the recurrence object is used to expand occurrences. Wall-clock time stays consistent — a 10:00 AM weekly meeting stays at 10:00 AM even when clocks change.
## Monthly bookings on day 31
If the start date falls on a day that doesn't exist in a target month (e.g. 31 January → February), the occurrence is clamped to the last valid day of that month (28 or 29 February).
## Gotchas
- **No modification API** — there is no endpoint to reschedule a series. Cancel it and create a new one.
- **`created_count` and `skipped_count`** in the series response show what was created versus skipped due to calendar conflicts.
- **If ALL occurrences conflict**, the series is deleted and an error is returned — no partial series is left behind.
---
# CLI
The `vennio` CLI lets you manage booking links, check bookings, and fire test bookings without leaving your terminal.
## Installation
```bash
npm install -g vennio
```
## Commands
### `vennio init`
Interactive setup wizard. Prompts you to paste an API key, optionally create a schedule, create your first booking link, and fire a test booking. Stores config at `~/.vennio/config.json`.
### `vennio status`
Shows your current config and verifies connectivity to the Vennio API:
```bash
vennio status
# API Key: vennio_sk_live_...xxxx
# Business ID: uuid
# API: connected ✓
```
### `vennio links list`
Lists all your booking links — title, shortcode, duration, active state, and public URL.
### `vennio links create`
Interactive wizard to create a new booking link. Prompts for title, duration (15 / 30 / 45 / 60 min), and an optional description.
### `vennio bookings list`
Lists bookings. Supports filtering flags:
| Flag | Description |
|---|---|
| `--status confirmed\|pending\|cancelled` | Filter by booking status |
| `--limit N` | Maximum number of results to return |
### `vennio bookings create`
Create a booking from the terminal. All flags are optional — the CLI prompts for anything not supplied:
| Flag | Description |
|---|---|
| `--name` | Customer name |
| `--email` | Customer email |
| `--start` | Start time (ISO 8601) |
| `--end` | End time (ISO 8601) |
| `--demo` | Use demo/placeholder values |
### `vennio test`
Fires a demo booking for tomorrow at 10:00–10:30 AM and prints the full booking record. Useful for verifying your API key and `businessId` config are working.
### `vennio open [shortcode | --docs]`
Opens a booking link or the documentation in your browser:
```bash
vennio open my-shortcode # opens https://vennio.app/b/my-shortcode
vennio open --docs # opens docs.vennio.app
```
## Config file
Stored at `~/.vennio/config.json`:
```json
{
"apiKey": "vennio_sk_live_...",
"baseUrl": "https://api.vennio.app",
"businessId": "your-business-id"
}
```
## Gotchas
- **API key must start with `vennio_sk_`** — validated on `init`. Publishable (`pk`) keys will be rejected.
- **`businessId` is required for booking creation** and is not auto-discovered. It is set during `init` or can be added manually to the config file.
- **Config is global** (`~/.vennio/`), not per-project. All projects on the machine share the same config unless you edit the file directly.
---
# Booking Widget
`@vennio/widget` is a drop-in iframe widget. Two lines of HTML embed the full booking flow on any page. When a booking completes, an `onBooked` callback fires with the booking details.
## Installation
**CDN (no build step):**
```html
```
**npm:**
```bash
npm install @vennio/widget
```
## HTML embed (auto-init)
Place a `div` with `data-vennio-booking` and the script tag. The widget initialises automatically:
```html
```
## JavaScript embed (programmatic)
For more control, initialise the widget from your own code:
```javascript
import { init } from '@vennio/widget'
init(document.getElementById('booking-widget'), {
shortcode: 'your-shortcode',
onBooked: (data) => console.log('Booked:', data)
})
```
## Options
| Option | Type | Default | Description |
|---|---|---|---|
| `shortcode` | `string` | — | Required. The Venn Link shortcode to embed |
| `baseUrl` | `string` | `https://api.vennio.app` | Override the API base URL |
| `height` | `string` | `700px` | Height of the iframe (any valid CSS dimension) |
| `width` | `string` | `100%` | Width of the iframe (any valid CSS dimension) |
| `onBooked` | `(data: BookingData) => void` | — | Callback fired when a booking completes |
## BookingData callback shape
```typescript
interface BookingData {
bookingId: string
startTime: string // ISO 8601
endTime: string // ISO 8601
customerName: string
customerEmail: string
}
```
## Customization
`height` and `width` accept any valid CSS dimension (e.g. `600px`, `80vh`, `100%`). Theming inside the booking form is not supported — the UI is rendered inside a hosted iframe page.
## Gotchas
- **The widget is an iframe** — you have no direct DOM access to the booking form inside it.
- **Communication is postMessage only.** The widget fires a `vennio:booking:complete` event on the parent window when a booking completes.
- **Invalid shortcode:** the widget logs an error to the console and renders nothing. Check the browser console if the widget appears blank.
- **Origin check:** the widget uses `includes('vennio.app')` to validate the parent origin. `localhost` passes intentionally, so local development works without extra configuration.
---
# Availability Picker
There is no single `VennAvailabilityPicker` export. Instead, `@vennio/react` gives you two composable primitives:
- **`TimeSlotPicker`** — a presentational component that renders slots. Does not fetch any data.
- **`useSlots`** — a hook that fetches available slots for a Venn Link.
Combine them to build a fully custom availability picker.
## TimeSlotPicker
Renders available time slots grouped by date as a grid. Slot data must be supplied via props — the component does not fetch anything itself.
### Props
| Prop | Type | Description |
|---|---|---|
| `slots` | `Slot[]` | Array of slot objects to display |
| `selectedSlot` | `Slot \| null` | Currently selected slot |
| `onSelect` | `(slot: Slot) => void` | Called when a slot is clicked |
| `primaryColor` | `string` | Accent colour for the selected state |
| `className` | `string` | Additional CSS class names |
| `loading` | `boolean` | Shows a loading skeleton when true |
| `emptyMessage` | `string` | Message shown when `slots` is empty |
## useSlots hook
Fetches available slots for a Venn Link by calling `GET /v1/venn-links/:shortCode/availability`.
### Options
| Option | Type | Default | Description |
|---|---|---|---|
| `shortCode` | `string` | — | Required. The Venn Link shortcode |
| `baseUrl` | `string` | From provider | Override the API base URL |
| `apiKey` | `string` | From provider | Override the API key |
| `autoFetch` | `boolean` | `true` | Fetch on mount automatically |
### Return value
| Key | Type | Description |
|---|---|---|
| `slots` | `Slot[]` | Available slots |
| `loading` | `boolean` | True while fetching |
| `error` | `string \| null` | Error message if fetch failed |
| `refetch` | `() => void` | Manually trigger a new fetch |
## Composition example
```typescript
import { TimeSlotPicker, useSlots } from '@vennio/react'
import { useState } from 'react'
export function AvailabilityPicker({ shortCode }) {
const { slots, loading, error } = useSlots({ shortCode })
const [selected, setSelected] = useState(null)
return (
)
}
```
## Using the full booking flow
If you want slot selection, a customer details form, and a confirmation screen all in one component, use `BookingCalendar` instead. `TimeSlotPicker` + `useSlots` is for cases where you need full control over the UI.
## Gotchas
- **`TimeSlotPicker` is presentational** — there is no built-in date navigation. If you want to let users browse different weeks or date ranges, implement date filtering yourself and pass the filtered slots.
- **`useSlots` does not auto-refetch.** Call `refetch()` manually if the user changes timezone or if significant time has passed since the initial fetch.
- **Slot times are ISO 8601 strings.** Format them for display using `date-fns` or `Intl.DateTimeFormat` — the component does not localise times automatically.
---
# How do I add scheduling/booking to my app?
Adding scheduling to your app requires three things: check availability, create a booking, and handle confirmation. Vennio provides a REST API and JavaScript SDK that do all three in a few lines of code.
## The answer
To add scheduling to your app with Vennio:
1. Install `@vennio/sdk`
2. Call `GET /v1/availability/slots` to show open times to your user
3. Call `POST /v1/bookings` when the user picks a slot
Vennio handles the rest: calendar event creation, confirmation emails to both parties, double-booking prevention, and webhook delivery.
## Install the SDK
```bash
npm install @vennio/sdk
```
Server-side only
Use a secret key (`vennio_sk_live_*`) only in server-side code. For client-side booking widgets, use [@vennio/react](/sdks/react) with a publishable key (`vennio_pk_live_*`).
## Get available slots
Query availability for your business. Returns open time slots accounting for existing calendar events and bookings.
```javascript title="get-slots.js"
import { createVennioClient } from '@vennio/sdk'
const client = createVennioClient({ apiKey: process.env.VENNIO_API_KEY })
const res = await client.availability.getSlots({
query: {
business_id: 'YOUR_BUSINESS_ID',
duration_minutes: 30,
timezone: 'America/New_York',
},
})
console.log(res.data.slots)
// [
// { start: "2026-04-01T09:00:00-05:00", end: "2026-04-01T09:30:00-05:00", ... },
// { start: "2026-04-01T10:00:00-05:00", end: "2026-04-01T10:30:00-05:00", ... }
// ]
```
```bash title="curl"
curl "https://api.vennio.app/v1/availability/slots?business_id=YOUR_BUSINESS_ID&duration_minutes=30&timezone=America%2FNew_York" \
-H "Authorization: Bearer vennio_sk_live_YOUR_KEY"
```
## Create a booking
Once the user picks a slot, create the booking. Vennio confirms it immediately and fires side effects automatically.
```javascript title="create-booking.js"
const bookingRes = await client.bookings.create({
body: {
business_id: 'YOUR_BUSINESS_ID',
customer_email: 'customer@example.com',
customer_name: 'Jane Doe',
start_time: '2026-04-01T09:00:00-05:00',
end_time: '2026-04-01T09:30:00-05:00',
},
})
const booking = bookingRes.data.booking
console.log('Booking confirmed:', booking.id, booking.status)
// Booking confirmed: 550e8400-... confirmed
```
```bash title="curl"
curl -X POST https://api.vennio.app/v1/bookings \
-H "Authorization: Bearer vennio_sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"business_id": "YOUR_BUSINESS_ID",
"customer_email": "customer@example.com",
"customer_name": "Jane Doe",
"start_time": "2026-04-01T09:00:00-05:00",
"end_time": "2026-04-01T09:30:00-05:00"
}'
```
```json title="Response"
{
"booking": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "confirmed",
"business_id": "YOUR_BUSINESS_ID",
"customer_name": "Jane Doe",
"customer_email": "customer@example.com",
"start_time": "2026-04-01T09:00:00-05:00",
"end_time": "2026-04-01T09:30:00-05:00",
"created_at": "2026-03-31T10:00:00Z"
}
}
```
## Add a booking page (no code)
If you want a shareable booking link without building a custom UI, create a Venn Link. Vennio hosts the booking page for you.
```bash
curl -X POST https://api.vennio.app/v1/venn-links \
-H "Authorization: Bearer vennio_sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "30-minute intro call",
"duration_minutes": 30,
"business_id": "YOUR_BUSINESS_ID"
}'
```
The response includes a `url` field — share that link and customers can book directly without any frontend work on your part.
## Next steps
- [Quick Start](/overview/quickstart) — end-to-end guide under 10 minutes
- [GET /v1/availability/slots](/api/platform-apis/getavailabilityslots) — full parameter reference
- [POST /v1/bookings](/api/platform-apis/create-direct-booking) — full request body reference
- [POST /v1/venn-links](/api/venn-links/createvennlink) — shareable booking pages
- [Webhooks](/overview/webhooks) — react to `booking.created` and `booking.cancelled`
---
# What is the best scheduling API for developers?
Vennio is a scheduling API built for developers — REST-first, composable, and designed to be embedded into any application. It exposes atomic primitives (availability, bookings, venn-links, proposals) that you combine to build exactly the scheduling flow your app needs.
## The answer
A good scheduling API for developers should:
- Return availability in a format your frontend can render directly
- Accept a booking in a single API call with no extra steps
- Handle calendar sync, conflict detection, and notifications automatically
- Support AI agents and MCP tools, not just humans
- Be composable — primitives you combine, not an opinionated SaaS product
Vennio is designed around all five. The API version is [1.2.0](/overview/introduction) with 125 documented operations across 18 resource tags.
## Core primitives
Vennio exposes four composable scheduling primitives:
- **Availability** — query open slots for any business, single or multi-party
- **Bookings** — create, list, and cancel confirmed calendar appointments
- **Venn Links** — hosted shareable booking pages with short codes
- **Proposals** — send time options to participants for async scheduling
## Availability API
`GET /v1/availability/slots` returns bookable slots for a business, filtered by duration and date range. Authenticate with your API key; you can query your own business, hosts you manage, and people connected to you or who have granted you consent.
```bash
curl "https://api.vennio.app/v1/availability/slots?business_id=YOUR_BUSINESS_ID&duration_minutes=30&timezone=America%2FNew_York" \
-H "Authorization: Bearer vennio_sk_live_YOUR_KEY"
```
```json title="Response"
{
"business_id": "YOUR_BUSINESS_ID",
"duration_minutes": 30,
"timezone": "America/New_York",
"total_slots": 14,
"slots": [
{ "start": "2026-04-01T09:00:00-05:00", "end": "2026-04-01T09:30:00-05:00", "duration_minutes": 30 },
{ "start": "2026-04-01T10:00:00-05:00", "end": "2026-04-01T10:30:00-05:00", "duration_minutes": 30 }
]
}
```
## Bookings API
`POST /v1/bookings` creates a booking in one call. Side effects — calendar event, confirmation emails, webhooks, CRM sync — happen automatically.
```javascript
import { createVennioClient } from '@vennio/sdk'
const client = createVennioClient({ apiKey: process.env.VENNIO_API_KEY })
const res = await client.bookings.create({
body: {
business_id: 'YOUR_BUSINESS_ID',
customer_email: 'customer@example.com',
customer_name: 'Jane Doe',
start_time: '2026-04-01T09:00:00-05:00',
end_time: '2026-04-01T09:30:00-05:00',
},
})
console.log(res.data.booking.status) // "confirmed"
```
Idempotency
All write operations accept an `Idempotency-Key` header. Retry safely without risk of duplicate bookings.
## Mutual availability
`GET /v1/availability/mutual` finds slots where multiple people are simultaneously free — the core primitive for multi-party scheduling.
```bash
curl "https://api.vennio.app/v1/availability/mutual?principal_ids=UUID1,UUID2&duration_minutes=60&timezone=UTC" \
-H "Authorization: Bearer vennio_sk_live_YOUR_KEY"
```
## Next steps
- [Quick Start](/overview/quickstart) — make your first booking in under 10 minutes
- [Authentication](/overview/authentication) — API keys, publishable keys, JWT
- [Platform APIs reference](/api/platform-apis) — all 125 operations
- [Vennio for Agents (MCP)](/guides/mcp-integration) — connect Claude or any MCP-compatible agent
---
# How can AI agents schedule meetings?
AI agents can schedule meetings through Vennio using either MCP (Model Context Protocol) tools or the REST API directly. Vennio exposes 13 MCP tools covering availability lookup, booking creation, proposal negotiation, and network queries — purpose-built for agentic scheduling workflows.
## The answer
Two integration paths for AI agents:
- **MCP tools** — connect Vennio to Claude, Cursor, or any MCP-compatible agent. The agent calls structured tools; Vennio handles calendar sync and booking confirmation automatically.
- **REST API / SDK** — your agent calls `POST /v1/bookings` directly. Works with any language or AI framework.
## MCP tools available
Vennio's MCP server exposes 13 tools your agent can call:
- `find_availability` — bookable slots for a business
- `find_mutual_availability` — slots where multiple people are free
- `create_booking` — create a confirmed booking
- `get_booking` — retrieve booking details
- `cancel_booking` — cancel an existing booking
- `propose_meeting` — send time options to participants
- `respond_to_proposal` — accept, counter, or reject a proposal
- `get_proposal` — get proposal status and thread
- `check_consent` — verify calendar access permissions
- `get_network` — view network overview
- `list_connections` — list network connections
- `enrich_meeting` — add context to a meeting from web data
- `find_mutual_connections` — find shared network connections
## Connect an agent via MCP
Add Vennio to your MCP client config. Here's the Claude Desktop configuration:
```json title="~/.claude/claude_desktop_config.json"
{
"mcpServers": {
"vennio": {
"command": "npx",
"args": ["-y", "@vennio/mcp"],
"env": {
"VENNIO_API_KEY": "vennio_sk_live_YOUR_KEY"
}
}
}
}
```
Once connected, your agent can schedule meetings in natural language:
```text title="Example agent prompt"
"Schedule a 30-minute call with customer@example.com tomorrow morning.
Check availability first, then create the booking and send confirmation."
```
Consent-scoped access
For agents that access another user's calendar (mutual scheduling), the user must grant consent first. See [Vennio for Agents (MCP)](/guides/mcp-integration) for the consent flow.
## Programmatic scheduling
For agents built with custom frameworks, call the REST API directly:
```javascript title="agent-scheduler.js"
import { createVennioClient } from '@vennio/sdk'
const client = createVennioClient({ apiKey: process.env.VENNIO_API_KEY })
// Step 1: Find available slots
const availability = await client.availability.getSlots({
query: {
business_id: 'YOUR_BUSINESS_ID',
duration_minutes: 30,
timezone: 'America/New_York',
},
})
const slots = availability.data.slots
if (slots.length === 0) {
return 'No availability found in the requested window.'
}
// Step 2: Book the first suitable slot
const booking = await client.bookings.create({
body: {
business_id: 'YOUR_BUSINESS_ID',
customer_email: 'customer@example.com',
customer_name: 'Jane Doe',
start_time: slots[0].start,
end_time: slots[0].end,
notes: 'Scheduled by AI assistant',
},
})
return `Booked: ${booking.data.booking.id} at ${slots[0].start}`
```
## Agent audit trail
When an agent creates a booking, pass `agent_name` in the request body (via the MCP `create_booking` tool or directly in the API). This appears in booking records so humans can see which agent took action.
```bash
curl -X POST https://api.vennio.app/v1/bookings \
-H "Authorization: Bearer vennio_sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"business_id": "YOUR_BUSINESS_ID",
"customer_email": "customer@example.com",
"customer_name": "Jane Doe",
"start_time": "2026-04-01T09:00:00Z",
"end_time": "2026-04-01T09:30:00Z",
"agent_name": "My Scheduling Agent v1"
}'
```
## Next steps
- [Vennio for Agents (MCP)](/guides/mcp-integration) — full MCP setup guide with consent flow
- [Firecrawl + Vennio MCP](/guides/firecrawl-integration) — enrich meetings with web research
- [POST /v1/bookings](/api/platform-apis/create-direct-booking) — REST API reference
- [GET /v1/availability/slots](/api/platform-apis/getavailabilityslots) — availability reference
---
# How do I prevent double-bookings in my app?
Vennio prevents double-bookings at the API level. The availability endpoint only returns genuinely open slots, and the booking endpoint performs a conflict check at creation time — returning a 409 Conflict if the slot is no longer available. Idempotency keys prevent duplicate bookings from retried requests.
## The answer
Vennio handles double-booking prevention in three layers:
1. **Availability filtering** — `GET /v1/availability/slots` only returns slots not blocked by existing calendar events or confirmed bookings
2. **Conflict detection at booking time** — `POST /v1/bookings` checks for conflicts atomically and returns `409 Conflict` if the slot was taken between the availability check and the booking request
3. **Idempotency keys** — prevent duplicate bookings from network retries or double-clicks
## Availability check before booking
Always query availability before presenting slots to users. The availability API respects your business hours, existing calendar events, and confirmed bookings.
```javascript title="check-availability.js"
import { createVennioClient } from '@vennio/sdk'
const client = createVennioClient({ apiKey: process.env.VENNIO_API_KEY })
const res = await client.availability.getSlots({
query: {
business_id: 'YOUR_BUSINESS_ID',
duration_minutes: 60,
timezone: 'UTC',
},
})
// Only genuinely open slots are returned
const openSlots = res.data.slots
console.log(`${openSlots.length} slots available`)
```
Calendar sync
Vennio syncs with Google Calendar (and Microsoft, coming soon). Existing events on your calendar are automatically excluded from available slots — no manual blocking required.
## Conflict response (409)
Even if you show a user an available slot, someone else might book it before they confirm. Vennio performs an atomic conflict check at booking creation time. Handle the 409 and refresh availability.
```javascript title="safe-book.js"
async function bookSlot(businessId, slot, customer) {
try {
const res = await client.bookings.create({
body: {
business_id: businessId,
customer_email: customer.email,
customer_name: customer.name,
start_time: slot.start,
end_time: slot.end,
},
})
return { success: true, booking: res.data.booking }
} catch (err) {
if (err.status === 409) {
// Slot was taken — fetch fresh availability and let user pick again
return { success: false, reason: 'slot_taken' }
}
throw err
}
}
```
```json title="409 Conflict response"
{
"error": "conflict",
"message": "The requested time slot is no longer available.",
"code": 409
}
```
## Idempotency keys
Pass an `Idempotency-Key` header on booking creation requests. If the same key is used twice, Vennio returns the original response rather than creating a second booking. Use a UUID generated per booking attempt.
```javascript title="idempotent-booking.js"
import { randomUUID } from 'crypto'
const idempotencyKey = randomUUID() // generate once per attempt
const res = await client.bookings.create({
headers: {
'Idempotency-Key': idempotencyKey,
},
body: {
business_id: 'YOUR_BUSINESS_ID',
customer_email: 'customer@example.com',
customer_name: 'Jane Doe',
start_time: '2026-04-01T09:00:00Z',
end_time: '2026-04-01T10:00:00Z',
},
})
// Safe to retry with the same idempotencyKey — won't create a duplicate
```
```bash title="curl with idempotency key"
curl -X POST https://api.vennio.app/v1/bookings \
-H "Authorization: Bearer vennio_sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"business_id": "YOUR_BUSINESS_ID",
"customer_email": "customer@example.com",
"customer_name": "Jane Doe",
"start_time": "2026-04-01T09:00:00Z",
"end_time": "2026-04-01T10:00:00Z"
}'
```
## Buffer time between meetings
To prevent back-to-back bookings without breathing room, pass `buffer_minutes` when querying availability. Slots that would leave less than the buffer time between meetings are excluded.
```bash
# Require 15 minutes between meetings
curl "https://api.vennio.app/v1/availability/slots?business_id=YOUR_BUSINESS_ID&duration_minutes=30&buffer_minutes=15&timezone=UTC" \
-H "Authorization: Bearer vennio_sk_live_YOUR_KEY"
```
## Next steps
- [GET /v1/availability/slots](/api/platform-apis/getavailabilityslots) — full availability parameters
- [POST /v1/bookings](/api/platform-apis/create-direct-booking) — booking creation reference
- [Rate Limits & Errors](/overview/rate-limits) — full error code reference
- [Webhooks](/overview/webhooks) — get notified on `booking.cancelled` to free up slots
---
# How do I build a Calendly alternative?
Building a Calendly alternative requires three primitives: a shareable booking page, an availability API to show open slots, and a booking API to confirm appointments. Vennio provides all three as composable API endpoints — you own the frontend, Vennio handles the scheduling logic, calendar sync, and confirmations.
## The answer
A Calendly-style product has three moving parts:
1. A booking page with a URL someone can share (**Venn Links**)
2. An availability grid showing open slots (**Availability API**)
3. A booking form that confirms the appointment (**Bookings API**)
With Vennio you can have a working end-to-end flow in under an hour. Use the hosted Venn Link if you don't want to build a UI, or build a fully custom frontend with the API and `@vennio/react` widget.
## Architecture overview
```text
User visits booking URL
└── GET /v1/venn-links/:shortcode ← public, no auth needed
User selects duration / date
└── GET /v1/venn-links/:shortcode/availability ← public, returns open slots
User fills in name + email, clicks confirm
└── POST /v1/venn-links/:shortcode/book ← creates booking
Vennio side effects (automatic):
├── Calendar event created
├── Confirmation emails to host + guest
└── booking.created webhook fired
```
## Create a booking page
A Venn Link is a shareable booking page with a short code. Create one per meeting type.
```bash
curl -X POST https://api.vennio.app/v1/venn-links \
-H "Authorization: Bearer vennio_sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "30-minute intro call",
"duration_minutes": 30,
"business_id": "YOUR_BUSINESS_ID"
}'
```
```json title="Response"
{
"venn_link": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"short_code": "abc123",
"title": "30-minute intro call",
"duration_minutes": 30,
"url": "https://book.vennio.app/abc123",
"business_id": "YOUR_BUSINESS_ID"
}
}
```
Share the `url` field. Vennio hosts the default booking page at that URL. To build a custom UI, use the short code to fetch availability and accept bookings yourself (see below).
## Show availability
Fetch open slots for the Venn Link. This endpoint is public — no API key required.
```bash
# Public endpoint — no auth required
curl "https://api.vennio.app/v1/venn-links/abc123/availability?timezone=America%2FNew_York"
```
```json title="Response"
{
"slots": [
{ "start": "2026-04-01T09:00:00-05:00", "end": "2026-04-01T09:30:00-05:00" },
{ "start": "2026-04-01T10:00:00-05:00", "end": "2026-04-01T10:30:00-05:00" },
{ "start": "2026-04-01T14:00:00-05:00", "end": "2026-04-01T14:30:00-05:00" }
]
}
```
## Accept bookings
When the user submits the booking form, call the book endpoint. Also public — no API key needed on the frontend.
```javascript title="book.js"
// This runs in the browser — no API key needed
const res = await fetch('https://api.vennio.app/v1/venn-links/abc123/book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customer_email: 'customer@example.com',
customer_name: 'Jane Doe',
start_time: '2026-04-01T09:00:00-05:00',
end_time: '2026-04-01T09:30:00-05:00',
}),
})
const { booking } = await res.json()
console.log('Confirmed:', booking.id)
```
## Embed a booking widget
For a faster build, drop the pre-built React widget into your app instead of building a booking UI from scratch.
```bash
npm install @vennio/react
```
```jsx title="BookingPage.jsx"
import { VennioWidget } from '@vennio/react'
export default function BookingPage() {
return (
{
console.log('Booked:', booking.id)
}}
/>
)
}
```
Paid bookings
Add Stripe Connect to charge for appointments. Set `price_amount` and `price_currency` on the Venn Link. Vennio returns a `checkout_url` for payment before confirming the booking. See [Paid Bookings](/overview/paid-bookings).
## Next steps
- [POST /v1/venn-links](/api/venn-links/createvennlink) — Venn Link creation reference
- [GET /v1/venn-links/:id/availability](/api/venn-links/get-venn-link-availability) — availability for a booking page
- [POST /v1/venn-links/:id/book](/api/venn-links/book-via-venn-link) — accept bookings
- [React Components](/sdks/react) — pre-built widget
- [Paid Bookings](/overview/paid-bookings) — add Stripe payments
---
# JavaScript SDK
`@vennio/sdk` — A lightweight, zero-dependency JavaScript SDK for the Vennio API.
SDK in beta
The JavaScript SDK is in beta. The API surface may change. For production use, you can call the REST API directly.
## Installation
```bash
npm install @vennio/sdk
```
## Usage
Server-side only
`createVennioClient` with a secret API key (`vennio_sk_live_*`) must only be used in server-side code (Node.js, edge functions). Never use it in browser JavaScript or expose your secret key to clients. Use `@vennio/react` or publishable keys for client-side use.
```javascript
import { createVennioClient } from '@vennio/sdk'
const client = createVennioClient({
apiKey: process.env.VENNIO_API_KEY,
baseUrl: 'https://api.vennio.app', // optional
})
// Query availability
const { slots } = await client.availability.getSlots({
business_id: 'your-business-id',
duration_minutes: 30,
timezone: 'America/New_York',
})
// Create a booking
const { booking } = await client.bookings.create({
business_id: 'your-business-id',
customer_email: 'customer@example.com',
customer_name: 'Jane Doe',
start_time: slots[0].start,
end_time: slots[0].end,
})
```
## Embeddable widget
Add a booking widget to any webpage with 2 lines of HTML using `@vennio/widget`:
```html
```
Or programmatically via npm:
```javascript
import { init } from '@vennio/widget'
init(document.getElementById('booking-widget'), {
shortcode: 'your-shortcode',
onBooked: (data) => console.log(data)
})
```
For full widget options and gotchas, see the [Booking Widget guide](/guides/booking-widget).
---
# Python Quickstart
Use the Vennio API from Python with the `requests` library. No SDK required — just standard HTTP.
## Setup
```bash
pip install requests python-dotenv
```
```python
import os
import requests
from dotenv import load_dotenv
load_dotenv()
BASE_URL = "https://api.vennio.app"
API_KEY = os.environ["VENNIO_API_KEY"]
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
```
## Query availability
```python
def get_available_slots(business_id, duration_minutes=30):
response = requests.get(
f"{BASE_URL}/v1/availability/slots",
headers=HEADERS,
params={
"business_id": business_id,
"duration": duration_minutes,
"timezone": "America/New_York",
}
)
response.raise_for_status()
data = response.json()
return data["slots"]
slots = get_available_slots("your-business-id")
print(f"Found {len(slots)} available slots")
for slot in slots[:3]:
print(f" {slot['start']} — {slot['end']}")
```
## Create a booking
```python
def create_booking(business_id, slot, customer_email, customer_name):
response = requests.post(
f"{BASE_URL}/v1/bookings",
headers={**HEADERS, "Content-Type": "application/json"},
json={
"business_id": business_id,
"customer_email": customer_email,
"customer_name": customer_name,
"start_time": slot["start"],
"end_time": slot["end"],
}
)
response.raise_for_status()
return response.json()["booking"]
booking = create_booking(
business_id="your-business-id",
slot=slots[0],
customer_email="customer@example.com",
customer_name="Jane Doe"
)
print(f"Booking created: {booking['id']} ({booking['status']})")
```
## Handle webhooks (Flask)
```python
import hashlib
import hmac
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["VENNIO_WEBHOOK_SECRET"]
@app.route("/webhooks/vennio", methods=["POST"])
def handle_webhook():
signature = request.headers.get("X-Webhook-Signature", "")
expected = hmac.new(
WEBHOOK_SECRET.encode(),
request.get_data(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
return jsonify({"error": "Invalid signature"}), 401
event = request.json
print(f"Received event: {event['type']}")
if event["type"] == "booking.created":
booking = event["data"]["booking"]
# Handle new booking...
return jsonify({"received": True})
if __name__ == '__main__':
app.run(port=5000)
```
---
# React Components
`@vennio/react` — Drop-in React components for booking and scheduling UIs.
React package in beta
The `@vennio/react` package is in early beta. API surface may change.
## Installation
```bash
npm install @vennio/react
```
## VennioProvider
Wrap your app (or the subtree that uses Vennio components) with `VennioProvider`:
```jsx
import { VennioProvider } from '@vennio/react'
function App() {
return (
)
}
```
## BookingCalendar
Provider required
`BookingCalendar` must be rendered inside a `VennioProvider` ancestor. See the [VennioProvider](#vennioprovider) section above.
A complete booking calendar component that queries availability and handles booking creation:
```jsx
import { BookingCalendar } from '@vennio/react'
function SchedulingPage() {
return (
{
console.log('Booking confirmed:', booking.id)
}}
onError={(err) => console.error(err)}
/>
)
}
```
### BookingCalendar props
| Prop | Type | Required | Description |
|---|---|---|---|
| `shortCode` | `string` | Yes | The Venn Link shortcode to book |
| `baseUrl` | `string` | No | Override the API base URL |
| `apiKey` | `string` | No | Publishable API key (if not using provider) |
| `onBooked` | `(booking: Booking) => void` | No | Called when booking is confirmed |
| `onCheckout` | `(url: string) => void` | No | Called when a Stripe Checkout URL is produced |
| `onError` | `(error: Error) => void` | No | Called on error |
| `successUrl` | `string` | No | Redirect URL after successful checkout |
| `cancelUrl` | `string` | No | Redirect URL after cancelled checkout |
| `className` | `string` | No | Additional CSS class names |
| `primaryColor` | `string` | No | Primary accent colour (CSS value) |
The `Booking` object passed to `onBooked` contains:
- `booking.id` — booking identifier (e.g. `"booking_xyz789"`)
- `booking.status` — `"confirmed"`
- `booking.start_time` / `booking.end_time` — ISO 8601 strings
- `booking.customer_email`, `booking.customer_name`
## TimeSlotPicker
A presentational component that renders available time slots as a grid. **Does not fetch data** — pair it with `useSlots` to supply slots.
```jsx
import { TimeSlotPicker } from '@vennio/react'
```
### TimeSlotPicker props
| Prop | Type | Description |
|---|---|---|
| `slots` | `Slot[]` | Array of slot objects to display |
| `selectedSlot` | `Slot \| null` | Currently selected slot |
| `onSelect` | `(slot: Slot) => void` | Called when a slot is clicked |
| `primaryColor` | `string` | Accent colour for the selected state |
| `className` | `string` | Additional CSS class names |
| `loading` | `boolean` | Shows a loading skeleton when true |
| `emptyMessage` | `string` | Message shown when `slots` is empty |
Slots are grouped by date and rendered as a grid. For a composition example, see the [Availability Picker guide](/guides/availability-picker).
## BookingForm
A customer details form component for collecting name, email, and any custom fields before submitting a booking:
```jsx
import { BookingForm } from '@vennio/react'
console.log(booking)}
/>
```
## BookingConfirmation
A post-booking confirmation display component. Pass it the `Booking` object returned after a successful booking:
```jsx
import { BookingConfirmation } from '@vennio/react'
```
## useVennio hook
A combined data hook that fetches slots and creates bookings for a given Venn Link. Must be used inside a `VennioProvider` tree.
```tsx
import { useVennio } from '@vennio/react'
function BookingActions({ shortCode }) {
const { loading, error, getSlots, book } = useVennio({ shortCode })
// getSlots() → Promise
// book(slot, customer) → Promise
}
```
| Return | Type | Description |
|---|---|---|
| `loading` | `boolean` | True while a request is in flight |
| `error` | `string \| null` | Error message from the last failed request |
| `getSlots` | `() => Promise` | Fetch available slots for the shortcode |
| `book` | `(slot: Slot, customer: Customer) => Promise` | Submit a booking |
To read context values (apiKey, baseUrl), use `useVennioContext` instead.
## useSlots hook
Fetch available slots for a Venn Link. Calls `GET /v1/venn-links/:shortCode/availability`.
```jsx
import { useSlots } from '@vennio/react'
function AvailabilitySection() {
const { slots, loading, error, refetch } = useSlots({
shortCode: 'your-shortcode',
})
// ...
}
```
### useSlots options
| Option | Type | Default | Description |
|---|---|---|---|
| `shortCode` | `string` | — | Required. The Venn Link shortcode |
| `baseUrl` | `string` | From provider | Override the API base URL |
| `apiKey` | `string` | From provider | Override the API key |
| `autoFetch` | `boolean` | `true` | Fetch on mount automatically |
### useSlots return value
| Key | Type | Description |
|---|---|---|
| `slots` | `Slot[]` | Available slots |
| `loading` | `boolean` | True while fetching |
| `error` | `string \| null` | Error message if fetch failed |
| `refetch` | `() => void` | Manually trigger a new fetch |
### Composition pattern
Compose `useSlots` and `TimeSlotPicker` for a fully custom slot picker:
```typescript
import { TimeSlotPicker, useSlots } from '@vennio/react'
import { useState } from 'react'
export function AvailabilityPicker({ shortCode }) {
const { slots, loading, error } = useSlots({ shortCode })
const [selected, setSelected] = useState(null)
return (
)
}
```
For the full guide including gotchas, see the [Availability Picker guide](/guides/availability-picker).
---
# Changelog
All notable changes to this project will be documented in this file.
> **Note:** This file is the source of truth. `apps/docs/src/content/changelog.md` is auto-generated from this file on every docs build via `apps/docs/scripts/build-content.js` and is gitignored. Do not edit the generated file directly — changes will be wiped on the next build.
## [2026-10-19] — Naming: Vennio for Agents and Vennio for Developers
### Changed
- **The MCP server is now named "Vennio for Agents".** The hosted server at `mcp.vennio.app/mcp`, the `@vennio/mcp` npm package (now 1.3.0) and `GET /.well-known/ai-agent?format=mcp` all report `Vennio for Agents` as their name, replacing `Vennio Scheduling`. The npm package's runtime version now matches its published version (it reported 1.1.0 while publishing 1.2.0). Tool names, schemas and behaviour are unchanged.
- **The docs site is now "Vennio for Developers".** The MCP guide is retitled "Vennio for Agents (MCP)" at the same URL, and the marketing site's nav links to the docs as "Developers".
## [2026-10-18] — Security: webhook delivery cannot reach internal addresses
### Security
- **Webhook deliveries no longer follow redirects and connect only to the address that passed validation.** The sender resolved your endpoint's hostname once, checked it, then let the HTTP client resolve again and follow any 3xx. A DNS answer that changed between the two lookups, or a redirect to an internal URL, made the API fetch from inside its own network. Now every address DNS returns is checked, the socket is pinned to the checked address (Host header and TLS SNI still carry your hostname), and a 3xx is recorded as a failed attempt (`error_message: "HTTP 302: redirect not followed"`) and retried on the usual schedule. The blocked ranges now include `0.0.0.0/8`, `100.64.0.0/10`, `168.63.129.16`, `fe80::/10`, `::`, and IPv4-mapped / NAT64 spellings of any blocked IPv4 range. Signature headers, delivery statuses, and the retry schedule are unchanged.
- **`GET /v1/webhooks/{id}/deliveries` no longer returns `response_body`, and new deliveries no longer store it.** The first 1000 bytes of your endpoint's reply were kept and shown to the webhook owner. `response_status` and `error_message` remain; the `WebhookDelivery` schema in the spec now matches the fields actually returned (`attempts`, `max_attempts`, `response_status`, `error_message`, `next_retry_at`, `payload`).
- **Direct client writes to `webhooks` are removed (migration `20261018_lock_client_writes_webhooks.sql`, apply separately).** A signed-in Supabase client could insert or update a webhook row directly, skipping the API's URL validation. All writes go through `POST /v1/webhooks`, `PUT /v1/webhooks/{id}`, `DELETE /v1/webhooks/{id}` and MCP `register_webhook`, which use the service role. Owner reads are unchanged.
## [2026-10-18] — Security: consents are enforced at the point of use
### Security
- **A consent's scopes and `time_range` are now checked when availability is read.** `GET /v1/availability/slots?consent_id=…` verified only that the consent was active and granted to the caller, so a consent holding just `booking:propose` read the grantor's bookable slots, and the `time_range` saved on `POST /v1/consents` was never consulted. Consent-ID mode now returns `403` unless the consent carries `availability:read:freebusy` or `availability:read:details`. On every availability path that relies on a consent (`consent_id` mode, `GET /v1/availability/slots`, `/next`, `/mutual`, `GET /v1/principals/{id}/availability`, and the MCP `find_availability` and `find_mutual_availability` tools) a consent with a `time_range` must contain the requested `from`/`to` window, or the request returns `403` naming the permitted window. Self, managed-host and connection access are unchanged. The MCP `check_consent` tool accepts optional `from`/`to` so an agent can check a window before reading. Response shapes are unchanged.
## [2026-10-18] — Security: consent requests carry a verified-identity flag
### Security
- **Forged consent requests can no longer become consents.** A row in `calendar_access_requests` could be inserted with the public anon key naming any email next to any `requester_id`; approving it granted the consent to that id. Approval now refuses (`400`) unless the stored `requester_id` verifiably owns `requester_email` in Supabase Auth, and a migration (`supabase/migrations/20261018_access_requests_lock_client_writes.sql`, applied by hand) removes client INSERT/UPDATE/DELETE on the table. All writes go through the API.
### Changed
- **`AccessRequest` has a new `requester_verified` boolean.** `true` when the request came from an authenticated principal (MCP `request_consent`); `false` when it came from the public `POST /v1/access-requests` form, where anyone can enter any email. Unverified requests are stored with `requester_name: null`, so a name cannot lend credibility to an unproven email. MCP `list_consent_requests` returns the same flag as `grantee_verified`.
- **`POST /v1/access-requests` caps `message` at 1000 characters and `requester_name` at 200.**
## [2026-10-18] — Security: customer_email on public booking links is verified
### Security
- **`GET /v1/venn-links/{shortCode}/availability` no longer trusts a bare `customer_email`.** The route subtracted the named customer's consented busy times for any caller, so anyone with a public link code and an email could read that customer's free/busy by comparing the response with and without the parameter. `customer_email` is now honoured only when the request also carries that customer's own Supabase session (`Authorization: Bearer `) with a matching auth email, compared case-insensitively. Anonymous, API-key and other-user callers get the business's availability only, identical to a call without the parameter, and `mutual_availability` is `true` only when the session matched. The response shape is unchanged.
- **`POST /v1/venn-links/{shortCode}/book` links a booking to a customer account only for that customer's own session.** The booking was linked to whichever account's profile email matched the body's `customer_email`, and the consent flag was read from that account. Bookings from anonymous or non-matching callers now carry `customer_id: null` and `customer_calendar_consent: false`. The booking itself, its `customer_email`, and the confirmation emails are unchanged.
## [2026-10-06] — Security: identity comes from your login, not your profile
### Security
- **The API now takes a caller's email from their verified login, never from their profile row.** A signed-in user could rewrite the email on their own profile, and the API treated that email as proof of who they were. Booking lists and lookups for customers, calendar access requests, and the link between a booking and a customer account now check the address Vennio Auth has confirmed. A profile that only claims an address gets nothing. `GET /v1/me` and the `get_me` MCP tool report the verified login email.
- **Users can no longer change `email` or `role` on their own profile.** The database rejects those writes from the browser. The profile email follows the login email, and every change to either field is recorded.
### Added
- **`POST /v1/me/role` sets a role at onboarding.** Send `{ "role": "business" }` or `{ "role": "customer" }` with a signed-in session. It works once, while the account has no role. Sending the same role again returns it unchanged; a different role returns `409`. API keys and connected-app tokens get `403`.
## [2026-10-01] — Guest connect links
### Added
- **`POST /v1/calendars/google/connect-guest-link` mints a single-use guest connect URL.** Call it from your server with a secret key when a customer clicks "Connect calendar", then redirect them to `connect_url`. The link works once and expires after 5 minutes. Your account is the business, taken from the key. `reference` and `redirect_url` are bound into the link, and `redirect_url` is checked against your whitelist when you mint. The result is reported as before: a `consent.granted` webhook, plus `principal_id` and `reference` on the redirect. Limited to 10 mints per minute per account.
### Changed
- **`GET /v1/calendars/google/connect-guest` with a raw `principal_id` is retired for new integrations.** It now returns `410 connect_guest_link_required` for every business except existing embeds that predate the link endpoint. Anyone who knew a business ID could start a connect to that business; a minted link can't be forged. Move to `POST /v1/calendars/google/connect-guest-link`.
## [2026-09-29] — Security: guest connect is rate-limited
### Security
- **`GET /v1/calendars/google/connect-guest` is now rate-limited.** Each call creates a guest user, and the route is unauthenticated, so a loop could create users without limit. It now allows 5 calls per minute and 20 per hour per IP, and 50 per hour per business, and returns `429` with `Retry-After` above that. `principal_id` must be a single v4 UUID; other spellings (braces, no hyphens, `principal_id[]=`) return `400 invalid_business`. Real traffic peaks at 3 per minute, so existing embeds are unaffected.
## [2026-09-28] — Security: calendar-connect redirects must be https
### Security
- **`redirect_url` on guest connect and host connect links must now be `https:` (or `http:` on localhost), with no fragment.** The check looked only at the hostname, so `javascript://vennio.app/…` passed. The portal's Google callback page then navigated to it, running script on the portal origin for any signed-in user who opened a crafted link. The API refuses such URLs (`400 invalid_redirect_url`), the OAuth callbacks drop any stored before this fix, and the portal page only follows https links. The same rule now applies to `redirect_url` on `POST /v1/oauth/connect-token` (a fragment returns `400`) and to `success_url` / `cancel_url` on `POST /v1/venn-links/{shortCode}/book`.
## [2026-09-28] — Security: publishable keys read only your own availability
### Security
- **Publishable keys can no longer read the availability of connected or consenting users.** A publishable key ships in browsers, so it now reaches only your own account and the hosts you manage on `GET /v1/availability/slots` and `/next`, and on the API's built-in MCP `find_availability` and `find_mutual_availability` tools. `consent_id` mode, `GET /v1/availability/mutual`, `GET /v1/principals/{id}/availability`, `/v1/network` and team availability now require a secret key or user session and return `403` for publishable keys. No publishable key had used these routes.
## [2026-09-28] — Guest connect reports who connected
### Added
- **Guest calendar connect now tells the business which principal connected.** When a customer finishes `GET /v1/calendars/google/connect-guest`, Vennio fires `consent.granted` to the business with `source: "calendar_connect"`, the customer's `grantor_id`, `connection_id` and your `reference`. It also appends `principal_id` and `reference` to your `redirect_url`. Previously the marketplace learned only `google_connected=true` and had to diff its connection list to find the new customer.
- **`reference` parameter on connect-guest.** Pass your own identifier (for example a trip ID); it comes back on the redirect and the webhook. Your whitelisted `redirect_url` must match exactly, so this is the way to carry your own context through the connect.
### Fixed
- **Guest connect no longer attempts a second consent row with an unknown scope.** The insert failed silently on every connect; the connection's own consent is unchanged.
## [2026-09-28] — Security: connect-token no longer creates connections
### Security
- **`POST /v1/oauth/connect-token` now returns `400 principal_id_not_supported` when `principal_id` or `business_id` is sent.** Previously any signed-in user could name any principal, complete Google OAuth with their own account, and get an active connection to that principal. A connection counts as consent for availability reads, so this exposed the named principal's free/busy. The token now connects the caller's own calendar only. A business connects customers with an invitation or `GET /v1/calendars/google/connect-guest`. No first-party app sent these fields.
## [2026-09-28] — Multi-host: mutual availability works with managed hosts
### Fixed
- **`GET /v1/availability/mutual` now accepts hosts your account manages.** Delegated authority previously covered bookings only. A marketplace querying a host together with a customer got `403`, because the account has no connection to its own hosts. Managed hosts now pass the access check the same way they do for `POST /v1/bookings`. The customer must still be connected to the account or have granted it consent. Consent stays with the account, so a customer who connects once can be queried alongside any of its hosts. Hosts managed by another account still return `403`. The same rule applies to the API's built-in MCP `find_mutual_availability` tool and team availability. `GET /v1/principals/{principalId}/availability` now uses the shared rule too: it also accepts connected principals, and returns `403` rather than `404` for unknown IDs.
## [2026-09-28] — Security: availability slots require authentication
### Security
- **`GET /v1/availability/slots` and `GET /v1/availability/next` now require authentication and access to the principal.** Previously both answered without credentials for any principal ID, computing free slots from that person's synced calendar, consumers included. Callers now need one of: the principal is themselves, a host their account manages, connected to them, or a grantor of `availability:read:freebusy` consent to them. Anything else returns `403`, including unknown principals, so the endpoint can't be used to enumerate IDs. Unauthenticated calls return `401`. The API's built-in MCP `find_availability` tool applies the same rule. Public booking pages are unaffected: they use `GET /v1/venn-links/{shortCode}/availability`. A publishable key can still read its own business's slots.
## [2026-09-28] — Security: connections can only change through the API
### Security
- **Clients can no longer create, re-point or reactivate connections.** A customer could previously update their own connection row to name a different business, or turn a blocked connection back to active, directly through Supabase. An active connection counts as consent for availability reads, so this let a customer read another user's free/busy. Connections are now written only by the API. No evidence of misuse was found in production.
### Added
- **`DELETE /v1/network/connections/{id}`** ends an active connection. Either party may call it; the connection is kept as `inactive`. The portal's "Disconnect" now uses it.
### Removed
- **Portal "Browse" tab and self opt-in.** A customer connecting to any business on their own would grant themselves access to that business's calendar. Connections start from an invitation. The opt-in call had been failing since the consent-on-connect trigger shipped.
## [2026-09-25] — Security: OAuth connector tokens only work on MCP
### Security
- **OAuth access tokens issued to connected apps (`vennio_at_…`) now work only on the MCP endpoint.** Every REST route returns `403` for them. Previously a token from any dynamically registered client acted as a full secret key, so a user tricked into approving a malicious app could have it mint a persistent API key that outlived "Disconnect". No such key or grant was found in production. The claude.ai connector only uses MCP and is unaffected.
- **Two MCP tools are restricted for connected apps.** `register_webhook` is unavailable to every OAuth token, because a webhook keeps sending data after the app is disconnected. The consent tools (`grant_consent`, `request_consent`, `respond_to_consent_request`, `withdraw_consent_request`) are unavailable to unverified apps. An app is verified when all its redirect URIs are on a recognised host (currently `claude.ai`). Secret API keys are unaffected.
- **The OAuth consent page can no longer be spoofed.** It shows the app's registered name, not one supplied in the link. It shows where access will be sent, and warns "Unverified app" for anything not redirecting to a recognised host. The page also no longer follows a non-web `redirect_uri` when you click Deny: a crafted link could previously run script on `app.vennio.app` that way. The scope list shows only real scopes, as the server records them.
### Added
- **`GET /oauth/clients/{client_id}`** returns a registered client's name, redirect hosts and verified status for the consent page.
## [2026-08-21] — MCP: `find_mutual_availability` accepts up to 10 principals
### Changed
- **`@vennio/mcp` `find_mutual_availability` now accepts 2–10 principals** (was 2–5), matching the REST API's engine bound. The previous `max(5)` in the published MCP server rejected 6–10-participant queries client-side before they reached the API, even though the engine has always accepted up to 10. Aligns the last of three cap surfaces (REST engine, the API's built-in MCP, and this standalone package). The `find_mutual_connections` bound is unchanged (separate endpoint, its own 2–5 limit). Released as `@vennio/mcp` 1.2.0.
### Added
- **`when.vennio.app` now works for people who don't use Google or Microsoft** — including iCloud-only users. A new **"Continue with email"** option (Supabase magic-link) provides identity, then a **Connect iCloud** screen captures an Apple ID + app-specific password (Apple has no calendar OAuth; iCloud is CalDAV) and links it via the existing `POST /v1/calendars/apple/connect`. The connect screen walks through creating the app-specific password (deep link + three steps), trims whitespace, reassures that the password is calendar-scoped and revocable, and maps specific errors (bad credentials vs. no calendars found) without signing the user out. Available on the Landing and invite-accept screens.
**Known limitation (by design):** events written to an Apple calendar on accept are private time-blocks — no iCloud invite email and no video link. In a mixed Apple/Google meeting the other side still sends a normal invite; an Apple-organized meeting simply has no auto video link (`when` doesn't surface one anyway). Sign in with Apple (identity) was intentionally deferred in favour of email magic-link — its OAuth client secret is a JWT requiring rotation roughly every six months, which isn't worth the maintenance while magic-link serves the same users.
## [2026-08-19] — when: Microsoft (Outlook) calendar support via unified sign-in
### Added
- **`when.vennio.app` now supports Microsoft users.** Sign in with Microsoft (Azure) and that single grant becomes your calendar connection — the Microsoft mirror of the existing unified Google auth (one consent covers identity + calendar, so the wrong calendar can't be attached). The Landing screen offers both "Sign in with Google" and "Sign in with Microsoft".
- **`POST /v1/calendars/microsoft/link`** — the Microsoft equivalent of `/google/link`: stores a connection from the sign-in grant's Graph tokens, with an events-scope probe (`422 needs_consent` on a scope-short token) and token-account primary-calendar selection. Backed by a new shared `storeMicrosoftConnection` (mirrors `storeGoogleConnection`).
- **Teams meeting links on accepted proposals** — `MicrosoftProvider.createEvent` now honors the online-meeting request (`isOnlineMeeting`/`teamsForBusiness`), so an accepted proposal on the Microsoft side gets a Teams link, matching Google Meet. Cross-provider (Google ↔ Microsoft) propose → accept → both-calendars works end to end.
Requires the Supabase Azure provider and the API to share one Azure app registration (the same shared-client model Google uses); that infra config is a deployment prerequisite.
## [2026-08-19] — Email: never transmit to reserved/non-routable recipients
### Fixed
- **The email cron no longer sends to structurally-undeliverable addresses (reserved TLDs — `.test`/`.example`/`.invalid`/`.localhost`, and `example.*`).** The test suite mints these in bulk (e.g. `@vennio.test`), and — because tests run against the production database — the production email cron was transmitting thousands of them via Resend, each a guaranteed hard-bounce. The send path now checks the recipient at the actual send site and marks such rows `skipped` (a new terminal `email_deliveries` status — deliberately not `failed`, so it stays out of the deliverability/bounce metrics) without ever calling the provider. This **prevents reputational damage** from the synthetic-send firehose: the Resend dashboard shows delivery healthy and the one recent suppression was address-specific (its own bounce history), not domain-level — the risk was real and is now plugged before it landed. It does not fix the deeper issue that tests touch production.
## [2026-08-19] — when: clearer "wrong Google account" state on a proposal invite
### Added
- **The `when` responder page (`/p/:token`) now recognises when you're signed in as the wrong Google account and offers one-tap recovery.** Accepting an invite while signed into a *different* account than the one it was sent to used to fail with a generic error (the signed-in session takes precedence over the magic-link token server-side). Now that case shows a distinct screen — "Nearly there — wrong Google account" — naming the invited address in masked form, with a **Switch account** button that signs out and re-opens Google's account chooser, returning to the same invite. A dedicated analytics event (`respond_wrong_account`) separates this from real failures.
- **`GET /v1/proposals/by-token` returns `participant.email_masked`** — the invited address masked to first character + domain (e.g. `k***@gmail.com`). The endpoint is unauthenticated (the token is the only credential), so the full address is never exposed; the mask is fixed-length and does not leak the local-part length.
## [2026-08-17] — when: propose a time → both calendars (Stage 4), in-channel responder
### Added
- **`when.vennio.app` can now propose a time and have the recipient accept from their own email — the last product gate.** Tapping a mutual-free slot sends a standard-mode proposal; the recipient gets a magic-link email and lands on a `/p/:token` page *inside the when app* to accept or decline. Accepting writes the event into both calendars. Previously the propose screen was a placeholder that sent nothing.
- **`POST /v1/proposals` accepts an optional `responder_base_url`** (standard mode). It pins the responder magic-link to a specified first-party app origin (e.g. `https://when.vennio.app`) so recipients stay in the originating channel instead of hopping to the platform portal. The value is validated against the exact-origin first-party allowlist at both mint and send (a non-allowlisted origin is rejected `400`); when omitted, links default to the portal, so existing callers are unaffected.
## [2026-08-15] — when: unified auth migrates existing users to the calendar scope
### Fixed
- **`POST /v1/calendars/google/link` now rejects a token that can't read events, instead of storing a broken connection.** Under unified auth, an existing user's login can return a *cached* Google grant that lacks `calendar.events` (it can list the calendar but not read events). Linking such a token produced a `degraded` sync. Now the endpoint probes event access before storing; on failure it returns `422 { needs_consent: true }` and writes nothing (so a scope-short token can never clobber a good connection), and the `when` app forces a single fresh consent (`prompt=consent`) to obtain the scope — still not on every login. Existing users self-migrate with one extra consent; a degraded connection is treated as needing repair.
## [2026-08-15] — when.vennio.app: unified Google auth (sign-in is the calendar connection)
### Changed
- **`when.vennio.app` sign-in now grants calendar access in the same Google consent** (unified auth), and stores the connection via a new `POST /v1/calendars/google/link`. Previously identity (Supabase Google login) and calendar (a separate OAuth) were two independent grants, so a user with multiple Google accounts could sign in as one account and silently attach a *different* account's calendar — with no feedback and no way to fix it. One grant means identity and calendar come from the same account and can't diverge. `prompt=consent` is used only to repair a missing connection, never on every login. The stored calendar and its primary-calendar selection are always set to the token's own account (replacing stale selections). The portal/business calendar-connect flow is unchanged.
## [2026-08-15] — Calendar sync no longer reports "synced" when it fetched nothing
### Fixed
- **A calendar sync whose event fetch fails is now marked `degraded` (with the reason), not `synced`.** Previously a per-calendar fetch failure (e.g. a wrong-account `404`) was swallowed: the connection was still recorded as `sync_status: synced, sync_error: null`, and the availability rebuild deleted the user's existing busy blocks before writing nothing — so a transient failure both hid itself and wiped good data. Now: a total fetch failure marks the connection `degraded`, records the error, and preserves the last-good blocks (does not advance `synced_at`); a partial failure is `degraded` but the succeeding calendars still write; a genuine empty calendar (0 events, no error) stays `synced`. This silent failure had masked a wrong-account calendar bug through several review passes.
## [2026-08-14] — when.vennio.app trusted as a first-party origin
### Fixed
- **The `when` consumer app now works when served from its production domain `when.vennio.app`.** The custom domain is bound and serving, but `https://when.vennio.app` wasn't in the API's trusted-origin allowlist, so the app's own API calls were CORS-blocked and calendar-connect rejected its redirect. Added `https://when.vennio.app` to the shared allowlist (`api/config/allowedOrigins.js`). The raw SWA origin stays trusted too — both host names answer the same app — until that host is deliberately retired.
## [2026-08-14] — when: load failures are distinct from empty states
### Fixed
- **The `when` app no longer shows its "invite someone" empty screen when a data load fails.** Home rendered the empty/invite state whenever the connections call errored — which silently swallowed a hard CORS failure and masked a real bug for two review rounds. Home and the overlap view now render a distinct "Something went wrong — try again" state (with retry) on a failed load, and only show the empty result on a *successful* load that genuinely returned nothing.
## [2026-08-13] — Fix: when SPA deep routes 404 on Azure SWA
### Fixed
- **`when` deep routes (e.g. the OAuth return `/auth/callback`, `/i/:token`, `/home`) no longer 404 on the Azure Static Web App.** `staticwebapp.config.json` (the `navigationFallback` that rewrites unknown routes to `index.html`) sat at the app root, which Vite does not copy into `dist/` — and the deploy uploads `dist/`. So Azure never received the fallback config and served its own 404 for anything but `/`. Moved the config into `apps/when/public/`, which Vite copies to the `dist/` root, so Azure applies it.
## [2026-08-13] — Fix: trust the when consumer SWA origin
### Fixed
- **The `when` consumer app's Azure SWA origin is now a trusted first-party origin.** `when.vennio.app` can't bind its custom domain (the old Static Web App is wedged/undeletable), so the app is served from its raw `*.azurestaticapps.net` origin — which was never in the API's allowlist. Every cross-origin call from the app to `api.vennio.app` was browser-blocked (connections, invites, calendar checks), and the `native_callback` calendar-connect gate rejected it too. The origin is added to the shared allowlist (`api/config/allowedOrigins.js`), fixing both CORS and calendar-connect. It carries a dated drop-when-bound note and is removed once `when.vennio.app` binds.
## [2026-08-13] — Calendar connect: native server-side callback
### Changed
- **`POST /v1/oauth/connect-token` accepts `native_callback`.** When set, the Google OAuth callback runs entirely server-side (`GET /v1/calendars/google/callback`) and redirects the browser straight back to `redirect_url` — no intermediate portal-domain page. Consumer apps on a `vennio.app` subdomain (e.g. `when.vennio.app`) now keep the whole calendar-connect flow on their own domain instead of bouncing through `vennio.app`, removing a drop point. The portal exchange flow is unchanged.
### Security
- **`native_callback` requires a trusted first-party app origin, enforced at two independent gates.** The normal `redirect_url` validation is an SSRF guard only (any public host), which is safe for the portal's client-side exchange but not for a direct server-side 302. So a `native_callback` token is rejected at mint unless `redirect_url`'s origin **exactly** matches an allowed first-party app origin (the same allowlist CORS uses — the `vennio.app` family plus `EXTRA_ALLOWED_ORIGINS`), and the callback re-validates at the redirect sink (and only for `native_callback` state) before honouring it on the error path. Exact-origin match only — a suffix match on a shared parent (e.g. `*.azurestaticapps.net`) or a lookalike would reopen the vector. Closes an open-redirect on the public callback endpoint.
## [2026-08-13] — API: open (consumer) invitation links
### Added
- **`POST /v1/invitations/link` mints an open, single-use share link — no recipient email required.** Where `POST /v1/invitations` addresses a known email and Vennio sends it, an open link carries no invitee: the creator shares it however they like, and whoever opens it and signs in with any account accepts. Accepting establishes the mutual connection and bidirectional `booking:propose` consent through the existing connect trigger, so overlap and proposals work immediately. JWT user sessions only — an API key cannot mint a link that represents a person's consent. A fresh token is minted per call, links default to a 30-day expiry, and minting is rate-limited. Backs the when.vennio.app consumer experience. (PR [#625](https://github.com/vennio-hq/my-porter/pull/625))
### Changed
- **`POST /v1/invitations/accept/{token}` now serves both invitation kinds.** Targeted (email-addressed) invitations are unchanged — the email-match gate still applies. Open links skip the match (any signed-in account may accept), reject self-acceptance, and return the `connection_id` plus the inviter's first name so a client can route straight to the shared availability. The public preview (`GET`) exposes only the inviter's first name for open links.
## [2026-08-12] — API: publishable keys restricted to the public surface
### Security
- **Publishable API keys (`vennio_pk_live_*`) can no longer reach mutating `/v1` routes.** Previously a publishable key held the same access as a secret key on every write endpoint — it could provision hosts, grant or revoke consent, send invitations, mint webhook secrets, and more. Publishable keys are now rejected (403) on all mutating routes outside the public surface; use a secret key (`vennio_sk_live_*`) or a user session for server-side operations. The public booking flow (`POST /v1/venn-links/{shortCode}/book` and availability reads) and magic-link accept paths are unchanged, so embedded booking widgets keep working. A CI gate now blocks any new mutating route that ships without a key-type check. (PR [#621](https://github.com/vennio-hq/my-porter/pull/621))
## [2026-07-28] — Dependency security residue cleanup
### Changed
- **API: removed unused `react-email` and `@react-email/components` dependencies** (email templates are hand-rolled HTML in `api/emails/`) — eliminates the socket.io/engine.io alert chain. Overrides added for `@opentelemetry/propagator-jaeger` ^2.9.0 and `@opentelemetry/core` ^2.8.0; in-range bumps for brace-expansion and body-parser. (Batch 4 of the security sweep.)
- **`@vennio/sdk`: dev-tooling overrides `js-yaml` ^4.3.0 and `esbuild` ^0.28.1** (lockfile-only; no runtime or API surface change).
## [2026-07-28] — MCP: SDK security update
### Changed
- **`@modelcontextprotocol/sdk` bumped 1.27.1 → 1.30.0 in the API's MCP endpoint and the `@vennio/mcp` stdio package.** Carries the `@hono/node-server` security fix (GHSA-frvp-7c67-39w9, CORS-adjacent request handling) — node-server now resolves to 2.0.12. Upstream 1.28–1.30 changes are stricter Content-Type parsing and SSE keep-alive frames on the Streamable HTTP transport; no tool, auth, or transport contract changes. (PR [#538](https://github.com/vennio-hq/my-porter/pull/538))
## [2026-07-14] — API: version 1.5.0
### Changed
- **`api/openapi.yaml` `info.version` bumped 1.4.0 → 1.5.0.** The spec version had not moved since 2026-03-09 despite shipped features (multi-host delegated authority, Apple Calendar, marketplace rate-limit tiers, OAuth 2.1 for remote MCP). The docs build now fails if the version shown on the docs introduction page drifts from the spec.
## [2026-07-06] — MCP: consent requests
### Added
- **Four MCP tools for grantee-initiated consent: `request_consent`, `respond_to_consent_request`, `list_consent_requests`, `withdraw_consent_request`.** Until now an agent without access could only `check_consent`, get `false`, and stop — there was no in-protocol way to ask. `request_consent` creates a pending request (idempotent: an existing pending request is returned, not duplicated); the grantor accepts or rejects with `respond_to_consent_request`; accepting creates a consent identical to one from `grant_consent`, so `check_consent`, availability, and proposals work unchanged. Built on the existing access-request flow — same `access_request.*` and `consent.granted` webhook events, no schema changes.
## [2026-07-04] — API: OAuth 2.1 for remote MCP connectors
### Added
- **OAuth 2.1 authorization server for remote MCP connectors (claude.ai).** Remote connectors can't send API-key headers, so the API now speaks the full OAuth flow: RFC 9728/8414 discovery at `/.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server` (issuer `mcp.vennio.app`), dynamic client registration (`POST /oauth/register`, PKCE-only public clients), authorization via a portal consent page, token exchange with mandatory PKCE S256 and refresh rotation (`POST /oauth/token`), and RFC 7009 revocation. Access tokens (`vennio_at_`, 1 h) act as the authorizing user; manage and revoke connected apps at `GET/DELETE /v1/me/connected-apps` or in the portal's API Keys page. (PR [#502](https://github.com/vennio-hq/my-porter/pull/502))
### Fixed
- **claude.ai remote connector showed the MCP server with no tools.** The MCP auth gate 401'd the protocol-mandated `notifications/initialized` message, aborting the handshake. JSON-RPC notifications and `ping` now pass unauthenticated; tool calls remain gated. (PR [#500](https://github.com/vennio-hq/my-porter/pull/500))
## [2026-06-28] — API: marketplace per-key rate-limit tier
### Changed (behaviour)
- **Self-assigned `rate_limit_per_hour` is capped at 10,000/hr on `POST /v1/api-keys`.** This was already enforced; it is now a named, tested boundary (`SELF_SERVICE_MAX_RATE_LIMIT`) so it can't regress silently. Marketplace / enterprise accounts that need a higher per-key bucket are granted an elevated tier out-of-band by an operator against an existing key — no key reissue — rather than self-assigning it at mint time.
## [2026-06-25] — API: Apple Calendar (iCloud) support
### Added
- **`POST /v1/calendars/apple/connect` — connect an iCloud calendar.** Apple has no OAuth, so you submit an Apple ID and a 16-character app-specific password (created at appleid.apple.com). Vennio validates it against iCloud over CalDAV, then syncs busy times from **every** calendar on the account into your availability. Booking events are written back to iCloud as self-only busy blocks — Vennio sends its own confirmation, and iCloud sends no duplicate invite to the guest. (PR [#491](https://github.com/vennio-hq/my-porter/pull/491))
### Changed (behaviour)
- **One active calendar provider at a time.** Connecting Apple replaces the currently active Google or Microsoft connection and clears its cached availability; the connect response reports which provider was replaced. (PR [#491](https://github.com/vennio-hq/my-porter/pull/491))
## [2026-06-24] — SDK + MCP: multi-host support
### Added
- **`@vennio/sdk` 0.4.0** — new `vennio.hosts` namespace: `create`, `list`, `createConnectLink`, and `unlink`. Provision and manage host principals, and get a hosted calendar-connect URL to send each host. `bookings.create()` and the availability calls already accept `principal_id` (alias for `business_id`) — pass a managed host's `principal_id` to book against / query that host. (PR [#487](https://github.com/vennio-hq/my-porter/pull/487))
- **`@vennio/mcp` 1.1.0** — new tools `provision_host`, `list_hosts`, `create_host_connect_link`, `delete_host`; `create_booking` now accepts `principal_id` so agents can book on behalf of a managed host. (PR [#487](https://github.com/vennio-hq/my-porter/pull/487))
### Documentation
- New guide: [Multi-Host Accounts](https://docs.vennio.app/guides/multi-host-accounts) — provision → connect-link → per-host availability → delegated booking, with a teams-vs-hosts callout. Multi-host added to the pricing comparison as a Scale-tier capability.
### Changed (release infra)
- **`@vennio/sdk` and `@vennio/mcp` now publish via npm trusted publishing (OIDC)** instead of a long-lived `NPM_TOKEN`. Release workflows run on Node 24 with npm ≥ 11.5.1 and `id-token: write`; `@vennio/sdk` gains a `repository` field (OIDC requires it to match the repo). Requires a one-time trusted-publisher config per package on npmjs.com. (PR [#489](https://github.com/vennio-hq/my-porter/pull/489))
## [2026-06-23] — API: multi-host delegated authority for enterprise accounts
### Added
- **`/v1/hosts` — manage many bookable hosts under one account.** Enterprise accounts (e.g. multi-coach L&D orgs) can now provision and manage many independently-bookable host principals with a few API keys, scaling to hundreds. `POST /v1/hosts` mints a host principal; `GET /v1/hosts` lists them with calendar-connection status; `POST /v1/hosts/{id}/connect-link` returns a single-use hosted Google calendar-connect URL to send to the host; `DELETE /v1/hosts/{id}` soft-unlinks. (PR [#480](https://github.com/vennio-hq/my-porter/pull/480))
### Changed (behaviour)
- **`POST /v1/bookings` accepts a managed host's `principal_id` (delegated authority).** Previously a booking's `principal_id` had to equal the authenticated account; it now also accepts any host the account manages, and the booking is attributed to that host and written to its calendar. Bookings list/get/update/cancel are likewise scoped to the account's managed principals, and `GET /v1/bookings?principal_id=` filters to one managed host. Single-host accounts are unaffected — the account is its own host. Each host's per-host availability is queried via the existing `GET /v1/availability/slots?principal_id=` path. (PR [#480](https://github.com/vennio-hq/my-porter/pull/480))
## [2026-05-28] — Docs: poll-mode proposals guide (Stage 8)
### Documentation
- **New guide:** [Poll-Mode Proposals](https://docs.vennio.app/guides/poll-mode-proposals). Walks the poll primitive end-to-end — named vs open variants, the create → distribute → vote → resolve → confirm → calendar-add flow, the Model 1 boundary (what the API knows vs what the builder owns), and two structurally-different worked examples (casual group event, demand-side B2B2C). Written to document the primitive, not any one builder's app — the test is "could you build a poll-shaped app you've never seen described from this." (PR [#TBD](https://github.com/vennio-hq/my-porter/pull/TBD))
- **`overview/webhooks.md`** now lists `proposal.resolved` with a dedicated payload section. (The webhook event list also lives in `info.description` of the OpenAPI spec — a known drift, logged for a future hygiene sweep.)
- **`sdks/proposals-widget.md`** documents poll-mode tokens (named vs open), the voter self-identification form for open tokens, and which widget callbacks fire vs don't in poll mode.
- **Provisional contracts** flagged in the new guide: `.ics` content shape (title hard-coded `Vennio meeting`, description format, ATTENDEE / PARTSTAT polish) and `status: 'accepted'` reuse for resolved polls — code defensively, refinements will ship with a deprecation window.
## [2026-05-28] — Proposals: uniform 401 on bad tokens + Stages 1-6a spec backfill
### Changed (behaviour)
- **Bad-token responses normalised to `401 invalid_token` across all token-auth endpoints.** `GET /v1/proposals/by-token` and `GET /v1/proposals/{id}/ics` previously returned `404 invalid_token` on unknown tokens; they now return `401 {error:'unauthorized', detail:'invalid_token'}`, matching `POST /v1/proposals/{id}/response` which already 401'd. One coherent rule: magic-link tokens are auth credentials, not resource lookups, so credential failures uniformly return 401. Deliberate behaviour change, made while there are no live consumers. (PR [#TBD](https://github.com/vennio-hq/my-porter/pull/TBD))
### Documentation
- **`api/openapi.yaml` brought up to date for Stages 1-6a.** Pure documentation of live code behaviour — no behaviour change beyond the 401 normalisation above. `Proposal` gains `proposer_name`, `proposer_email`, `team_id`; `ProposalParticipant` gains `full_name`, `email`, and `user_id` becomes nullable (open voters). `Proposal.status` description now includes the poll-mode lifecycle (reaper two-pass, resolution reuses `accepted`, open polls resolve only via close or expiry). `CreateProposalRequest` documents the poll-mode expiry floor (`earliest_slot_too_soon` 400), `expires_in_hours` being ignored in poll mode, and the `metadata.team_id` promotion. Per-endpoint error codes added: `400 earliest_slot_too_soon` on create; `400 poll_mode_only_supports_accept`, `401 missing_credentials`, `401 participant_unresolved`, `403 not_a_participant`, `409 you_have_already_responded` on response; full error matrix added on by-token. Prerequisite to Stage 8 (docs + llms.txt generate from this spec).
## [2026-05-28] — Proposals: poll-resolved webhook + .ics endpoint (Stage 6a)
### Added
- **`proposal.resolved` webhook event.** Fires when a poll-mode proposal resolves to a winning slot — covers both named polls (all-voted trigger) and open polls (organiser close, and the future expiry reaper). Payload: `{proposal_id, thread_id, poll_variant, winning_slot:{id, start, end}, vote_count, total_votes, tie_count}`. Delivered to the organiser's webhook subscribers (Model 1: the API knows the votes, the builder fans out to voters by their preferred channel). (PR [#TBD](https://github.com/vennio-hq/my-porter/pull/TBD))
- **`GET /v1/proposals/{id}/ics?token=...`** — returns the resolved poll as a `text/calendar` (.ics) file so a voter can tap a magic-link URL and add the meeting to their calendar without needing email. Token auth mirrors `GET /v1/proposals/by-token` (the magic-link token IS the credential). 409 `poll_not_resolved` until the poll resolves; 410 `token_expired` after the token's expiry. Deterministic iCal UID `proposal-{proposalId}-slot-{slotId}@vennio.app` — every retrieval of the same resolved poll produces the same UID, so calendar clients can reconcile organiser-side updates against a single shared event identity.
- **`ATTENDEE` roster on the .ics** lists voters who voted (`selected_slot_id IS NOT NULL`) with a resolvable email; the organiser is the `ORGANIZER`. Anonymous voters and non-voting invitees are omitted (RFC 5545 expects a CALAddress; mirrors the Model 1 boundary). Title, description, and RSVP/PARTSTAT polish are **provisional** — validated against real Apple/Google/Outlook clients in Job 2; expect refinements before GA.
## [2026-05-28] — Proposals: open voting + organiser close (Stage 5)
### Added
- **Open voting** — a new poll-mode variant for shareable polls. Create a poll with `mode: "poll"` and no `participant_emails` and the 201 returns a single `magic_link_url` instead of per-participant tuples. Anyone with the link can vote; voters self-identify at vote time via optional `voter_name` / `voter_email` on `POST /v1/proposals/{id}/response`. Email voters can revise their vote (re-POST same email updates the existing row); anonymous voters can't revise and can double-vote — the API gives basic resistance, stricter identity is the builder's surface to harden. Distinguished from named polls (per-participant magic links, Stages 2-4) via the new `poll_variant: 'named' | 'open'` field on `Proposal`. (PR [#444](https://github.com/vennio-hq/my-porter/pull/444))
- **`POST /v1/proposals/{id}/close`** — organiser-explicit close of a poll. Required for open polls (which have no all-voted trigger since the voter set is open-ended) and useful for named polls when the organiser doesn't want to wait. With ≥1 vote → resolves to the winning slot (ties broken by earliest `start_time`); zero votes → expires. Poll-mode only; standard proposals use `/cancel` (different semantics — cancel deletes calendar events from an accepted proposal, close terminates a still-`proposed` poll).
- **`GET /v1/proposals/by-token`** now returns `participant: null` for open-poll tokens (`token.kind: 'open'`); client UI must render a name/email field and create the participant on submit. For named tokens (`token.kind: 'named'`) `participant` is always populated as before.
- **Per-link rate limit** on `POST /v1/proposals/{id}/response` — 20 votes per 60 seconds per token by default, env-tunable via `OPEN_VOTE_RATE_LIMIT_MAX` / `OPEN_VOTE_RATE_LIMIT_WINDOW_MS`. Returns RFC 7807 on 429.
### Schema
- **`proposals.poll_variant`** column added (`'named' | 'open'`, NOT NULL when `mode='poll'`). Set at create time; every Stage 5+ branch reads this field rather than inferring mode from token shape.
- **`proposal_action_tokens`** — `participant_id` is now nullable, new `kind` column (`'named' | 'open'`), the original `UNIQUE (proposal_id, participant_id)` replaced with two partial UNIQUE indexes (one per kind). Open tokens are multi-use (skip the `used_at` flip on consume).
- **`proposal_participants` identity CHECK** relaxed to accept `display_name IS NOT NULL` so fully-anonymous voters pass (server synthesises `display_name='Anonymous'` when neither voter field is supplied).
## [2026-05-20] — Accepting a proposal no longer falsely reports an upstream calendar failure
### Fixes
- **Proposal accept resolves a participant's calendar lazily.** Previously, if a participant connected their calendar between proposal create and accept, the accept request would return `502 calendar_unavailable` — misleading, because the user's calendar was perfectly fine and Google was never called. The pin captured at create-time was simply still null. Accept now re-resolves the participant's primary calendar on the fly, persists it onto the participant row, and proceeds. (PR [#400](https://github.com/vennio-hq/my-porter/pull/400))
- **New distinct error for the truly-unpinned case:** if a participant still has no calendar at accept time, the response is now `400 participant_calendar_not_pinned` (with a human-readable message) rather than a generic `502`. `calendar_unavailable` is now reserved for genuine upstream Google failures, making future debugging less misleading.
## [2026-05-20] — Portal + marketing live on Azure Static Web Apps
### Infrastructure
- **Portal (`app.vennio.app`) cutover from Vercel to Azure Static Web Apps.** The React app is now served from the `vennio-portal` SWA in West Europe. The Vercel project is retained as a rollback target only — no new Vercel-specific config should be added. (PRs [#386](https://github.com/vennio-hq/my-porter/pull/386), [#384](https://github.com/vennio-hq/my-porter/pull/384), [#385](https://github.com/vennio-hq/my-porter/pull/385), [#399](https://github.com/vennio-hq/my-porter/pull/399))
- **Marketing site (`vennio.app`) and docs (`docs.vennio.app`) on SWA finalised.** `staticwebapp.config.json` is now served from each app's `public/` directory so SWA picks it up at build time. Marketing-site wildcard redirects had their trailing `/*` dropped from destinations (Azure SWA rejects them). (PRs [#395](https://github.com/vennio-hq/my-porter/pull/395), [#398](https://github.com/vennio-hq/my-porter/pull/398), [#382](https://github.com/vennio-hq/my-porter/pull/382))
- **301 redirect map from marketing apex to portal paths** (ADR-002): paths that previously lived on `vennio.app/*` and have moved to `app.vennio.app/*` now 301 at the marketing edge so external links and email templates don't break. (PR [#394](https://github.com/vennio-hq/my-porter/pull/394))
- **`api.vennio.app` CORS allowlist** now includes `app.vennio.app` so the cutover portal can call the API. (PR [#389](https://github.com/vennio-hq/my-porter/pull/389))
- **HubSpot OAuth redirect URL** registered for `app.vennio.app` ahead of cutover. (PR [#388](https://github.com/vennio-hq/my-porter/pull/388))
- **`vennio-api.service` systemd unit fixed.** `WorkingDirectory=/opt/vennio/api` pointed at an empty directory after the source-tree move; CI deploys were bypassing the unit by calling `docker compose` directly, masking the drift. The unit is now under deploy control with the correct path — `systemctl restart` and reboots will actually work. (PR [#383](https://github.com/vennio-hq/my-porter/pull/383))
### Repository structure
- **Portal source moved from `/src` to `/apps/portal/`** for monorepo symmetry with `apps/marketing` and `apps/docs`. (PR [#384](https://github.com/vennio-hq/my-porter/pull/384))
## [2026-05-19] — Portal URL configuration consolidated
### Operator-facing
- **Four portal URL environment variables collapsed into a single `PORTAL_URL`**, plus a centralised `getPortalUrl()` helper. Previous names (`VITE_PORTAL_URL`, `FRONTEND_URL`, `APP_URL`, etc.) drifted across services and were a source of inconsistent OAuth redirect URIs and email link bases. Operators running self-hosted deployments should set `PORTAL_URL` and remove the legacy variables. (PR [#387](https://github.com/vennio-hq/my-porter/pull/387))
## [2026-05-17] — Infrastructure docs tidy-up — defer to the audit doc
### Documentation
- **`infra/README.md` trimmed** from 284 lines to ~50. The duplicated operational sections (env vars, viewing logs, manual cron testing, rollback, troubleshooting) are removed — those live in [`docs/deployment/DEPLOYMENT.md`](docs/deployment/DEPLOYMENT.md). What remains: a tight IaC file inventory + the architecture diagram + the source→VM path mapping for each file. Each file's status is now explicit (active vs. historical vs. stale).
- **`infra/staging.md` banner** clarifying the staging environment is not implemented (no `vennio-api-staging-rg` exists), and flagging the doc's drifted facts (B2s vs actual D2s_v6, `/opt/vennio/api/` vs actual `/source/`, UK South vs actual West Europe). If staging is stood up in future it should be redone from current state, not from these steps.
- **`infra/cutover-env-vars.md` banner** clarifying the cutover this doc was written for completed months ago. Pointed at the audit doc's "Env vars actually set on the VM" section as current truth; flagged the drift items (most Paperclip keys, `WEBAPP_URL`, `API_BASE_URL`, `APPLICATIONINSIGHTS_CONNECTION_STRING` etc. listed in template but not on VM).
- **`docs/projects/marketing-site.md`** — DNS provider question resolved (Porkbun, confirmed in the audit). Pointer added to the audit doc as the verified infrastructure source.
- **`docs/adr/002-domain-split.md`** — pointer added to the audit doc in the Related references.
No code changes. No content changes other than banners + the README rewrite. This PR just stops competing sources of truth.
## [2026-05-17] — Infrastructure audit: rebuilt `docs/deployment/DEPLOYMENT.md` from evidence
### Documentation
- **[`docs/deployment/DEPLOYMENT.md`](docs/deployment/DEPLOYMENT.md) rebuilt from scratch.** The previous version had drifted (path errors, missing surfaces, missing Function App for docs). The new doc was assembled via a methodical audit: every claim is dated 2026-05-17 and traceable to a verification command, listed in a verification log at the end.
- **Coverage:** Azure subscription + resource group inventory, API VM (size, OS, IP, Tailscale), `/opt/vennio` file layout, all 6 cron systemd timers, marketing + docs SWAs, docs Function App, full DNS record map (Porkbun nameservers), every third-party service in use (Supabase, Google/MS/Salesforce/HubSpot OAuth, Stripe, Resend, Anthropic, Firecrawl, Sentry, PostHog, Paperclip, Tailscale, GHCR, Application Insights), GitHub Actions workflows + required secrets.
- **9 known-drift items** flagged with evidence — none breaking production today, none fixed in this PR. Includes the broken `vennio-api.service` systemd unit (`WorkingDirectory=/opt/vennio/api` points at an empty directory; CI deploys bypass it via direct `docker compose`, but `systemctl restart` or VM reboot would fail), the `EXTRA_ALLOWED_ORIGINS` duplicate entry, stray source-tree artefacts on the VM including unexpected `.env.local`/`.env.vercel.local` files that should not be on production hardware, missing `APPLICATIONINSIGHTS_CONNECTION_STRING`, and the Stripe webhook destination drift noted in CLAUDE.md.
- **[`infra/README.md`](infra/README.md) gets a banner** clarifying it documents the original Vercel → Azure migration sequence (historical) and pointing at the new deployment doc for current operations.
- **MEMORY entry** added pointing future Claude sessions at the new doc as the verified source of truth.
No code changes. No infrastructure changes. The audit identified work items; this PR ships the durable record.
## [2026-05-17] — Marketing site Phase 2: Scheduling Audit ported as a React island
### Marketing site
- **Scheduling Audit** ([`apps/marketing/src/pages/scheduling-audit.astro`](apps/marketing/src/pages/scheduling-audit.astro)) ported from `src/pages/SchedulingAudit.jsx`. Five-question form, A–F scoring across multi-party / conflict / timezone / cancellation / observability dimensions, plain-text report, email-capture lead form posting to `POST /v1/audit-leads`. Logic verbatim; visual surface refit to the editorial design language (Playfair headlines, mono Q1–Q5 eyebrows, hairline-bordered radio options with amber-accent selected state, ink terminal block for the report).
- **First React island in `apps/marketing/`.** The interactive form is a React component rendered as a `client:load` island inside the Astro page. Added `@astrojs/react` + `react` + `react-dom` to the marketing site. The static shell (hero, headings, design language) remains zero-JS; only the audit form hydrates.
- **Audit link in footer** Site column.
### Operational note
The audit form POSTs to `https://api.vennio.app/v1/audit-leads`. The API's CORS allowlist accepts `vennio.app`, `www.vennio.app`, `docs.vennio.app`, localhost. On SWA PR preview URLs (`-.westus2.7.azurestaticapps.net`) the call will fail until `EXTRA_ALLOWED_ORIGINS` on the API VM is updated. Production is fine.
Phase 2 progress: 5 of 5 marketing pages ported. Developers, Waitlist, vibe-coders, GetStarted dropped (dead / auth flow). **Phase 2 complete after this PR merges.**
## [2026-05-17] — Marketing site Phase 2: LandingPage ported, `/` is now the homepage
### Marketing site
- **Homepage** ([`apps/marketing/src/pages/index.astro`](apps/marketing/src/pages/index.astro)) ported from `src/pages/LandingPage.jsx`. Six sections: hero ("Infrastructure for Time" with italic-amber accent on "Time"), About Vennio (compliance copy), What it is (Stripe analogy), How it works (three step cards), Why Vennio (five differentiator rows), CTA band. Content verbatim; presentation uses the established editorial design language (Playfair regular headlines, mono eyebrows, hairline section dividers, ink/paper/amber palette).
- **`/` → `/blog` 302 redirect removed** from `staticwebapp.config.json`. The marketing site now serves an actual homepage at `vennio.app/`. Phase 1 placeholder behaviour retired.
- Both hero and CTA-band "Get Started" buttons route through `SITE.external.auth`.
**Known copy tension** flagged for follow-up (not changed here): the homepage "What it is" section retains "Scheduling infrastructure for developers" verbatim from the SPA, while the new `/about` copy uses "Scheduling infrastructure for builders." Deliberate verbatim port — the homepage messaging is a separate editorial decision, not a port concern.
Phase 2 progress: 4 of 6 marketing pages ported (Privacy, Terms, Pricing, Landing). Still pending: Developers, SchedulingAudit.
## [2026-05-17] — Marketing site Phase 2: Pricing ported to Astro
### Marketing site
- **Pricing page** ([`apps/marketing/src/pages/pricing.astro`](apps/marketing/src/pages/pricing.astro)) ported from `src/pages/Pricing.jsx`. Four flat-rate tiers — Free, Indie £29, Builder £99, Scale £299 — with "Most Popular" amber accent on Indie. Eleven-row feature comparison table. Content unchanged. Visual presentation aligned to the marketing editorial design language; `●` (amber) and `—` replace the ✅/❌ emoji for a less casual look.
- **`SITE.external.auth` constant** added to `lib/site.ts`. Every "Get Started" / "Sign in" CTA on the marketing site now reads from this single source — flips one line at Phase 4 cutover from `vennio.app/auth` to `app.vennio.app/auth`. Header Sign in button and all three Pricing CTAs use it.
- **Pricing in nav + footer.** Header nav: Blog · Pricing · Docs · Sign in. Footer Site column gains Pricing.
Phase 2 progress: 3 of 6 marketing pages ported (Privacy, Terms, Pricing). Still pending: Landing, Developers, SchedulingAudit.
## [2026-05-17] — Marketing site Phase 2: Privacy + Terms ported to Astro
### Marketing site
- **Privacy Policy** ([`apps/marketing/src/pages/privacy.astro`](apps/marketing/src/pages/privacy.astro)) and **Terms of Service** ([`apps/marketing/src/pages/terms.astro`](apps/marketing/src/pages/terms.astro)) ported from the SPA (`src/pages/Privacy.jsx`, `src/pages/Terms.jsx`). Content verbatim; visual presentation aligned to the marketing site's editorial design language (Playfair Display headlines regular weight, DM Sans light body, mono-uppercase eyebrows, hairline section dividers, no shadows). Service-provider table on Privacy updated: Vercel → Azure (West Europe) to reflect the API + docs migration that's already shipped.
- **Shared `LegalLayout.astro`** with the back-to-home link, eyebrow + title + last-updated header, and prose-styled body. Reusable for any future legal page (DPA, sub-processor list, AUP).
- **Footer adds Privacy + Terms links** in the Site column.
Phase 2 progress: 2 of 6 marketing pages ported. Still pending: Landing, Pricing, Developers, SchedulingAudit. Waitlist + vibe-coders + GetStarted dropped from the port list (dead / auth flow).
## [2026-05-17] — Marketing site scaffold + domain split plan
### Marketing site (Astro, Phase 1)
- **New app at `apps/marketing/`.** Astro 5 SSG project, deploys to Azure SWA at `vennio-marketing` (West Europe, Standard tier). Independent of the workspace; mirrors the `apps/docs/` deploy pattern. Same brand tokens, fonts (Playfair Display / DM Sans / DM Mono), and editorial design language as the existing `vennio.app` React SPA — visual continuity for the cutover ([ADR-002](docs/adr/002-domain-split.md)).
- **First blog post live at `/blog/best-scheduling-api-for-developers-2026`.** 6000-word comparison pillar covering 13 scheduling/calendar APIs. Carries Article + ItemList + FAQPage + BreadcrumbList + Organization + WebSite + Person JSON-LD; passes Google Rich Results Test on all five surfaces.
- **Routes:** `/` (302 → `/blog`), `/blog` index with Pagefind client-side search, `/blog/[...slug]`, `/about`, `/rss.xml`, `/sitemap-index.xml`, `/robots.txt`, `/llms.txt`.
- **Programmatic Open Graph images.** `src/lib/og.ts` renders 1200×630 PNGs via satori + resvg at build time; per-post OGs auto-generated from frontmatter so future posts ship with the right social card without manual asset production. Title font scales 76→52→44px based on character count to avoid overflow.
- **Brand monogram favicon suite.** Paper "V" on amber square, Playfair Display 600 — Variant A from `Vennio Design System/brand/monograms.html`. Four assets (`favicon.svg`, `apple-touch-icon.png`, `icon-192.png`, `icon-512.png`) all generated programmatically from the same satori pipeline.
### Architectural decisions
- **[ADR-002: Domain split](docs/adr/002-domain-split.md) accepted.** `vennio.app` becomes the marketing site (Astro SSG) at Phase 4 cutover; portal SPA moves to `app.vennio.app`. Decision documents: target structure, redirect map for all portal routes, OAuth whitelist checklist (ADR-001 dependency), phased migration plan, Phase 4 rollback strategy (90-day Vercel warm-keep with explicit rollback triggers).
- **Project record at [`docs/projects/marketing-site.md`](docs/projects/marketing-site.md).** Single source of truth: phase tracker, decisions log, open items, verification log. Resumable cold by any agent.
### Not yet shipped
- Phase 1 is preview-only — production `vennio.app` is still the Vercel SPA. SWA preview URL: `https://delightful-grass-0c36f151e.7.azurestaticapps.net` (production hostname; PR previews carry a `-` suffix and `X-Robots-Tag: none`).
- Phase 2 will port marketing pages (Landing, Pricing, Waitlist, GetStarted, Developers, SchedulingAudit, Privacy, Terms) from `src/pages/` into `apps/marketing/`. Phase 3–5 cover OAuth whitelist updates, DNS cutover, and the 90-day deprecation window.
## [2026-05-15] — Docs: dedicated `@vennio/proposals-widget` page
### Documentation
- **New page at `docs.vennio.app/sdks/proposals-widget`** ([source](apps/docs/src/content/sdks/proposals-widget.md)). Documents the demand-side embed widget — quick start, options, programmatic usage, event payload shapes, React companion, security/threat model for the token in the DOM, and a "when to use this vs. the raw REST endpoint" framing. Sibling to the JavaScript / Python / React SDK pages under "SDKs & TOOLS".
- Page is auto-discovered by the docs build (`build-content.js` scans `src/content/**/*.md` and regenerates `content-manifest.json`). Pre-rendered into the static build.
## [2026-05-15] — `@vennio/widget@0.1.4` + `@vennio/react@0.3.1` — repository URL refresh
### Bug fixes — package metadata
- **`@vennio/widget@0.1.4`** and **`@vennio/react@0.3.1`** — patch bumps to correct `repository.url`. Both packages pointed at the long-defunct `https://github.com/vennio-app/vennio` (the old org/repo name). Now: `git+https://github.com/vennio-hq/my-porter.git` in the npm-normalised form. No code change — metadata-only.
- The stale URL surfaced as an npm `publish` warning on `@vennio/react@0.3.0` (the version published earlier today) and `@vennio/widget@0.1.3` (last published 4 months ago). Fixing in tandem so both shipped packages stop redirecting npm users to a 404.
- No change required for `@vennio/proposals-widget@0.1.1` — its `repository.url` was correct at first publish.
## [2026-05-15] — OpenAPI: cross-reference the proposals widget from the response endpoint
### API documentation
- **`POST /v1/proposals/{id}/response` description now opens with a "For browser embeds: use the widget, not this endpoint" callout.** Surfaces `@vennio/proposals-widget` and `` from `@vennio/react` as the recommended path for browser-side recipient responses, with the raw REST endpoint reserved for server-side flows (webhooks, schedulers, adapters). Closes the discoverability gap where agents reading the spec went straight to writing fetch code and rebuilt the `the-cut`/`safe-space` mistake (calling the response endpoint with the organizer's API key → `403 organizer_cannot_respond`).
- **Proposals tag description gains the same callout.** An agent scanning the tag-level docs before any specific endpoint now sees the widget recommendation up front.
- No code or behaviour change. Spec change propagates to `docs.vennio.app/llms-full.txt` and the rendered docs site via the existing build pipeline on next deploy.
## [2026-05-15] — `@vennio/react@0.3.0` — `` component
### React
- **`@vennio/react@0.3.0`** — minor bump for the new `` component exported on 2026-05-15. The component embeds `vennio.app/p/:token` in an iframe and surfaces accept/counter/reject postMessage events as React callbacks. Same accept/counter/reject payload shapes as `@vennio/proposals-widget`. Inlined iframe with `useEffect` cleanup — no listener leak (the bug fixed in `@vennio/proposals-widget@0.1.1` never affected the React component).
- No behavioural changes to the existing `BookingCalendar` / `BookingForm` / `TimeSlotPicker` / `BookingConfirmation` components.
## [2026-05-15] — `@vennio/proposals-widget@0.1.1` — listener-leak fix
### Bug fixes
- **`init()` no longer leaks message listeners on re-mount.** Each call to `init(container, options)` registered a fresh `window.message` listener but never removed earlier ones bound to the same container. SPAs that re-initialise on token/prop change saw callbacks fire N times for one event. Surfaced by the 2026-05-15 manual iframe test (Mount clicked twice → `onAccepted` fired twice). Fix: track active handlers per container in a `WeakMap` and remove the prior listener before binding a new one.
- **`init()` now returns an unsubscribe function.** Backwards-compatible (callers ignoring the return are unaffected). Lets host apps manually tear down the widget; documented in `packages/proposals-widget/README.md`. The React `` component was unaffected by the leak (its `useEffect` cleanup already removed the listener) — no change there.
## [2026-05-15] — `@vennio/proposals-widget` (demand-side embed widget)
> **Backfilled entry.** PR #366 merged without a CHANGELOG entry because the R4 changelog-gate's tier-1 path list does not yet cover `packages/proposals-widget/`, `packages/react/`, `packages/widget/`, or the main app at `src/`. Gap surfaced in audit follow-up; gate-widening tracked separately.
### Widgets / SDK
- **`@vennio/proposals-widget@0.1.0` (new package).** Drop-in iframe widget that embeds Vennio's existing magic-link recipient view (`vennio.app/p/:token`) into any third-party site with 2 lines of HTML, mirroring the supply-side `@vennio/widget` pattern. Removes the `403 organizer_cannot_respond` footgun: embedding apps pass a token and react to postMessage events; they never call `POST /v1/proposals/{id}/response` themselves, so they cannot accidentally call it as the wrong principal. Three callbacks (`onAccepted` / `onCountered` / `onRejected`) plus a generic `onEvent` for terminal/error states (`expired`, `used`, `not_found`, `calendar_disconnected`, `error`). IIFE 2.57 KB, ESM 2.10 KB, CJS 2.57 KB. Origin-verified postMessage. Auto-init + MutationObserver. Not yet published to npm — see Phase A4 follow-up.
- **`@vennio/react` — new `` component.** React wrapper for the same flow. Inlined iframe (no inter-package runtime dependency). Same callback surface as the vanilla widget. Will publish as `@vennio/react@0.3.0` when ready.
### Frontend (`vennio.app/p/:token`)
- **Inline counter-propose UI** on the magic-link recipient page. Recipients can suggest 1-10 alternative slots with an optional message, sent as `action: counter` with `counter_slots`. New `COUNTERING` / `COUNTERED` states in the page state machine. Previously the page linked recipients into the portal to counter; the embedded widget now supports counter end-to-end without leaving the iframe.
- **postMessage emission on all terminal states.** When loaded inside an iframe (`window !== window.parent`), the page now posts `vennio:proposal:*` events to the parent on accept / counter / reject / expired / used / not_found / calendar_disconnected / error. This is the load-bearing prerequisite for the widget's callback surface. Target origin is `'*'` because third-party embeds cannot enumerate parent origins; the widget itself verifies `event.origin` on the receive side.
### Design context
- Implementation closes the structural gap identified in `audit/0c-doc-prose-vs-agent-behaviour-2026-05-15.md` (this morning's falsification of the doc-prose intervention). Spec at `audit/0c-proposals-widget-design.md` (PR #365).
## [2026-05-15] — Proposals: organizer-cannot-respond constraint surfaced
### API documentation
- **The Proposals tag description and `POST /v1/proposals/{id}/response` endpoint now explicitly state that the principal who created a proposal cannot accept, counter, or reject it.** Previously the rule was implicit in the dual-auth model (Bearer auth for organizer-side reads; magic-link token for recipient responses) and discoverable only by hitting a 403 mid-build. The 403 error code (`organizer_cannot_respond`) is now in the documented error code list alongside the existing 401/409 entries.
- **Architectural note added on testing end-to-end with a single API key:** create the proposal with your key, then respond using the recipient's magic-link token from the proposal-invitation email. Your own Bearer auth on the response side will be rejected.
No code or behaviour change — documentation only. Spec change propagates to `docs.vennio.app/llms-full.txt` and the rendered docs site via the existing build pipeline on next deploy.
## [2026-05-14] — SDK 0.3.0: availability namespace, deprecated routes removed
### SDK
- **`@vennio/sdk@0.3.0` published** to npm. Second curated-surface release. The
package is the recommended way to talk to the Vennio API from JS/TS apps and
AI agents.
- **New `vennio.availability` namespace** with two methods:
- `slots(params)` — bookable time slots for a single business or principal
in a date range. Returns a discriminated union (`kind: 'flat'` for a flat
`slots[]` array, `kind: 'grouped'` for `slots_by_day` keyed by ISO date
when `group_by_day: true` is passed). Supports public, authenticated, and
consent-scoped access modes; the `consent` audit-trail field on the
response populates only in consent-scoped mode.
- `findOverlap(params)` — mutual availability across 2-10 principals.
Returns the full intersection plus a `consents` array proving the caller
had authorization from each participant. Empty overlap returns 200 with
`slots: []` and a populated `consents` array (verified via production
probe 2026-05-11).
- **Uncurated namespaces named explicitly in the README.** Eight raw-only
namespaces (`network`, `eventTypes`, `consents`, `apiKeys`, `invitations`,
`accessRequests`, `hubspot`, `schedules`) are now documented in the README
with usage examples for `vennio.raw..`. Closes the
failure mode where Cursor/Copilot would recommend `vennio.eventTypes.create(...)`
— which doesn't exist on the modern class — instead of the working
`vennio.raw.eventTypes.create(...)`.
### Removed from SDK surface
- **`vennio.raw.offers`** (3 methods: `create`, `list`, `respond`). The
underlying `/v1/offers/*` routes were retired in May 2026; calls would have
returned 404. The SDK's generated client now omits them.
- **`vennio.raw.salesforce`** (7 methods). All `/v1/salesforce/*` routes are
`deprecated: true` in the spec; the Salesforce integration is being sunset.
If you currently depend on the Vennio Salesforce integration, contact the
team — the surface is being replaced.
### Release pipeline
- The SDK release workflow (`sdk-release.yml`) now runs a tag-sync guard
before publish. It fails the release if the pushed `sdk-v*` tag and
`packages/sdk/package.json:version` disagree. Closes the
0.2.0-vs-0.2.0-rc.1 mislabel class of bug structurally rather than relying
on manual discipline at release time.
- Generator filter added (`parser.filters.deprecated: false` in
`packages/sdk/openapi-ts.config.ts`) so any future routes marked
`deprecated: true` in `api/openapi.yaml` automatically drop out of the
SDK's public type surface on the next build.
## [2026-04-29] — OAuth security fix, Booking entity canonicalisation, stats schema alignment
### Security
- Calendar OAuth flow now requires a signed connect token instead of a `user_id` query parameter. Dashboard integrations are unaffected. Programmatic integrations need to call `POST /v1/oauth/connect-token` before initiating OAuth at `/v1/calendars/google/connect` or `/v1/calendars/microsoft/connect`.
### Bookings API
- List, detail, and creation endpoints now return a consistent canonical Booking shape with 21 documented fields. Internal calendar event IDs are no longer included in responses.
- The Venn Link book endpoint (`POST /v1/venn-links/{id}/book`) now references a new `BookingConfirmation` schema (5 fields) instead of the canonical Booking schema. The endpoint's behaviour hasn't changed — the spec now accurately reflects what it returns.
### Stats endpoints
- `GET /v1/bookings/stats` and `GET /v1/api-keys/{keyId}/stats` schemas updated to match handler output. The unused `last_7_days` field has been removed from API key stats responses.
### Calendar OAuth response
- The `redirectUrl` field returned by `POST /v1/calendars/google/exchange` has been renamed to `redirect_url` for consistency with the rest of the API surface.
## [2026-04-28] — Spec audit fixes, alignment, and infrastructure-first defaults
- Removed 15-minute minimum on `duration_minutes` across all availability and venn-link endpoints — now accepts any positive integer up to 480
- Documented four previously undocumented endpoints: `GET /v1/stripe/session-status`, `GET /v1/billing/invoices`, `GET /v1/calendars/connections`, `GET /v1/calendars/google/connect-guest`
- `GET /v1/stripe/session-status` made public per original intent — was incorrectly auth-gated, now rate-limited at 5 req/min/IP
- `POST /v1/access-requests` made public per original intent — was incorrectly auth-gated, now rate-limited at 5 req/min/IP
- Fixed 500 error on `GET /v1/invitations/{id}` caused by missing `validateUUID` import
- Fixed `POST /v1/invitations/bulk` spec to match handler — request body field names corrected after stale rewrite in `d3db26b`
- `principal_id` migration complete: now accepted as alias for `business_id` across all booking, availability, venn-link, offer, invitation, and booking-series endpoints
- Removed phantom `business_id` query param from `GET /v1/bookings/stats` spec — handler always uses `req.userId`
- Fixed Python f-string bug in code example generator that affected 108 of 131 auto-generated Python snippets
## [2026-04-12] — OAuth secret security fix and environment variable cleanup
- OAuth client secrets (`GOOGLE_CLIENT_SECRET`, `MICROSOFT_CLIENT_SECRET`) no longer use the `VITE_` prefix — prevents accidental exposure in the browser bundle
- Client-side debug page (`check-env.html`) no longer displays `VITE_GOOGLE_CLIENT_SECRET`
- Vercel environment variables audited across all three projects: server-side secrets removed from the frontend project, stale variables cleaned up from the API project
- Deployment and Azure setup docs updated with correct environment variable names
## [2026-04-01] — Billing, MCP package, audit tool, and agent-driven improvements
- **Stripe subscription billing** — plans table, subscription service, billing API routes, and platform fee collection via `application_fee_amount`
- **@vennio/mcp standalone package** — npm-publishable MCP server with Firecrawl web scraping/search tools, `enrich_meeting` calendar enrichment, and Smithery tool annotations
- **Scheduling Health Check** — `/audit` page with wizard flow, audit leads API, and personalised fix guide emails
- **OG image generation** — `/api/og/report-card` endpoint for dynamic social sharing images
- **Sentry alert webhook** — receives Sentry alerts and auto-creates Paperclip issues for triage
- **Redirect URI whitelist** — OAuth connect flow now validates redirect URIs against an allowlist
- Calendar events now sync using the business's configured timezone instead of hardcoded America/New_York
- API error responses no longer leak `error.message` in production — details routed through Sentry only
- All API logging migrated from `console.log` to structured Winston logger with Sentry error transport
- North Tyneside pilot: 5 venue landing pages with working OAuth connect and registered businesses
- Recurring bookings integration test suite added (291 lines, covers series CRUD and cancellation)
- Google OAuth scope fix: restored `calendar.events` scope, added regression tests
- MCP endpoint now allows unauthenticated `initialize` and `tools/list` for registry discovery
- Internal: CI updated for Node.js 24, docs AI chatbot evals suite, AGENTS.md quality rules for IC agents
## [2026-03-28] — OAuth security fixes, error tracking, and API test suite
- All calendar OAuth flows (Google, Microsoft, Salesforce) now use cryptographic nonces instead of exposing user IDs in state parameters
- Salesforce PKCE verifier moved from in-memory storage to database — no longer lost on serverless cold starts
- Sentry error tracking added across API and frontend with PII scrubbing, session replay, and source map uploads
- Sensitive console.log statements removed from OAuth flows — errors now routed through Sentry
- First API integration test suite: 21 tests covering booking CRUD and availability validation (vitest + real Supabase)
- `.env.example` added with all 30+ environment variables documented
- Internal: pre-commit hook fixed for git worktrees, vitest config corrected for sequential test execution
## [2026-03-27] — Security hardening, docs SEO, and venue improvements
- OAuth tokens (Google Calendar, Salesforce) are now encrypted at rest using AES-256-GCM — existing tokens migrated
- Database security tightened: RLS enabled on 3 previously unprotected tables, function search paths fixed across 23 functions
- Docs site now serves 153 pre-rendered HTML pages for SEO and AI crawlers, plus llms.txt, sitemap.xml, and robots.txt
- MCP transport now returns proper JSON responses in stateless mode
- Engine Room venue page redesigned with improved contrast and restructured information hierarchy
- Internal: removed stale Salesforce connection check script, cleaned up 14 remote branches with leaked credential history
## [2026-03-24] — Developer onboarding, guest OAuth, and Engine Room pilot
- API keys and developer credentials now visible across onboarding touchpoints
- Guest OAuth connect flow allows venue visitors to link calendars without a Vennio account
- Engine Room venue landing page live at engineroom.vennio.app
- Widget iframe URL updated from /book/ to /venn/ for consistency
## [2026-03-20] — Paid bookings, AI chatbot, and app shell redesign
- Businesses can now accept payments for bookings via Stripe Connect (onboarding, pricing, checkout)
- AI chatbot added to both docs site and main dashboard with user-context-aware responses
- App redesigned with sidebar navigation and new Home view with grouped matches
- Dashboards decluttered for intuitive self-serve experience
- Fixed tab-switching instability from subscription thrashing, polling leak, and unmount races
## [2026-03-14] — API platform expansion and CLI
- MCP server enables AI agents to find availability, create bookings, and manage proposals
- Team scheduling with cross-organisation members and consensus proposals
- Recurring bookings with series management and occurrence tracking
- Booking questions and custom fields on event types and venn links
- Idempotency keys for safe API retries, sandbox/test mode for development
- Google Meet links auto-created on bookings
- Network graph API for connection analytics
- `npx vennio` CLI with init wizard, status, links, bookings, and test commands
- Webhook test endpoint for debugging integrations
- Rate limit headers now included on all API responses
## [2026-03-06] — Design system overhaul
- Migrated entire UI from Tailwind CSS to CSS Modules with design tokens (`vennio-tokens.css`)
- Resolved all high, medium, and low-priority token gaps (colours, spacing, borders, sizing)
- Applied unified design system to docs site at docs.vennio.app
- Added `npm run sync-tokens` to propagate token changes to docs site
- Launched /vibe-coders marketing page for developer community
## [2026-03-02] — Developer docs site and deployment hardening
- Launched interactive documentation site at docs.vennio.app with API reference, search, and AI assistant
- Resolved 15 QA issues on docs site (broken links, markdown rendering, button routing)
- Added CORS OPTIONS preflight handling for Vercel edge compatibility
- Scoped `.vercelignore` to prevent hobby plan serverless function limit (12 max)
- Added 5 example projects: Node.js/Express, Python, Calendar Agent, Field Service Dispatch, Webhook Listener
- Archived obsolete CalendarMatch MVP setup guides (replaced by docs.vennio.app)
---
# API Reference
## Discovery
AI agent discovery endpoints for machine-readable API capabilities.
Supports Model Context Protocol (MCP) format.
9 endpoints:
### GET /.well-known/ai-agent
**AI Agent Discovery**
Returns machine-readable API capabilities for AI agents.
**Use Case**: AI agents (Claude, ChatGPT, etc.) can discover what this API can do
and how to authenticate.
Supports both custom Vennio format and Model Context Protocol (MCP) format.
Auth required: No
Docs: https://docs.vennio.app/api/discovery/discoveragentcapabilities
**Parameters:**
- `format` (query, string) — Response format (default is Vennio native format)
**Responses:**
- `200`: API capabilities
**Example:**
```bash
curl -X GET 'https://api.vennio.app/.well-known/ai-agent'
```
---
### GET /openapi.yaml
**OpenAPI Specification (YAML)**
Returns the full OpenAPI specification in YAML format.
Auth required: No
Docs: https://docs.vennio.app/api/discovery/getopenapiyaml
**Responses:**
- `200`: OpenAPI specification
**Example:**
```bash
curl -X GET 'https://api.vennio.app/openapi.yaml'
```
---
### GET /openapi.json
**OpenAPI Specification (JSON)**
Returns the full OpenAPI specification in JSON format.
Auth required: No
Docs: https://docs.vennio.app/api/discovery/getopenapijson
**Responses:**
- `200`: OpenAPI specification
**Example:**
```bash
curl -X GET 'https://api.vennio.app/openapi.json'
```
---
### GET /.well-known/oauth-protected-resource
**OAuth Protected Resource Metadata (RFC 9728)**
Describes the MCP endpoint as an OAuth 2.1 protected resource and points
clients at the authorization server. Remote MCP connectors (claude.ai)
fetch this after receiving a 401 with a WWW-Authenticate header.
Also served at `/.well-known/oauth-protected-resource/mcp` (path-suffixed
variant probed by some clients).
Auth required: No
Docs: https://docs.vennio.app/api/discovery/getoauthprotectedresourcemetadata
**Responses:**
- `200`: Protected resource metadata
**Example:**
```bash
curl -X GET 'https://api.vennio.app/.well-known/oauth-protected-resource'
```
---
### GET /.well-known/oauth-protected-resource/mcp
**OAuth Protected Resource Metadata — MCP path variant (RFC 9728 §3.1)**
Identical body to `/.well-known/oauth-protected-resource`. Served
because clients that scope metadata discovery to the resource path
(claude.ai) probe the path-suffixed variant.
Auth required: No
Docs: https://docs.vennio.app/api/discovery/getoauthprotectedresourcemetadatamcp
**Responses:**
- `200`: Protected resource metadata
**Example:**
```bash
curl -X GET 'https://api.vennio.app/.well-known/oauth-protected-resource/mcp'
```
---
### GET /.well-known/oauth-authorization-server
**OAuth Authorization Server Metadata (RFC 8414)**
Authorization server metadata for the embedded OAuth 2.1 server backing
remote MCP connectors. PKCE (S256) required; public clients only
(`token_endpoint_auth_methods_supported: ["none"]`).
Also served at `/.well-known/oauth-authorization-server/mcp`.
Auth required: No
Docs: https://docs.vennio.app/api/discovery/getoauthauthorizationservermetadata
**Responses:**
- `200`: Authorization server metadata
**Example:**
```bash
curl -X GET 'https://api.vennio.app/.well-known/oauth-authorization-server'
```
---
### GET /.well-known/oauth-authorization-server/mcp
**OAuth Authorization Server Metadata — MCP path variant (RFC 8414)**
Identical body to `/.well-known/oauth-authorization-server`; the
path-suffixed variant probed by resource-scoped discovery.
Auth required: No
Docs: https://docs.vennio.app/api/discovery/getoauthauthorizationservermetadatamcp
**Responses:**
- `200`: Authorization server metadata
**Example:**
```bash
curl -X GET 'https://api.vennio.app/.well-known/oauth-authorization-server/mcp'
```
---
### GET /v1/me
**Get Authenticated Identity**
Returns the authenticated principal's identity. Used to resolve "who am I"
— particularly useful for SDK clients and AI agents that need to fetch
their own `principal_id` (formerly `business_id`) before making
availability queries or creating bookings.
Authenticated via Bearer token (JWT or API key).
Auth required: Yes
Docs: https://docs.vennio.app/api/discovery/getme
**Responses:**
- `200`: Authenticated principal identity
```json
{
"principal_id": "11111111-2222-3333-4444-555555555555",
"name": "Ada Lovelace",
"email": "ada@example.com",
"workspace_id": "11111111-2222-3333-4444-555555555555"
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Authenticated principal not found in database
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/me' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/me/role
**Set Role At Onboarding**
Sets the signed-in user's role once, at onboarding. Accepts only
`business` or `customer`, and only while the account has no role.
Sending the role the account already has returns it unchanged; sending
a different one returns `409`.
Signed-in sessions (JWT) only. API keys and connected-app tokens get
`403`.
Auth required: Yes
Docs: https://docs.vennio.app/api/discovery/setmyrole
**Request Body:**
- `role`: string (required) — The role to set.
**Responses:**
- `200`: The account's role
```json
{
"principal_id": "11111111-2222-3333-4444-555555555555",
"role": "business"
}
```
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `409`: Resource conflict
```json
{
"type": "https://api.vennio.app/errors/conflict",
"title": "Conflict",
"status": 409,
"detail": "Resource already exists or conflicts with current state",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/me/role' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
## OAuth
OAuth 2.1 authorization server for remote MCP connectors (claude.ai).
PKCE-only public clients via dynamic client registration; opaque access
tokens act as the authorizing user. Canonical issuer: https://mcp.vennio.app.
8 endpoints:
### POST /oauth/register
**Dynamic Client Registration (RFC 7591)**
Registers a public OAuth client (PKCE-only; no client secret is issued).
Used by remote MCP connectors to self-register before the authorization
flow. Redirect URIs must be HTTPS. Rate limited per IP.
Auth required: No
Docs: https://docs.vennio.app/api/oauth/registeroauthclient
**Request Body:**
- `redirect_uris`: array (required)
- `client_name`: string
- `client_uri`: string
- `logo_uri`: string
- `grant_types`: array
- `token_endpoint_auth_method`: string
**Responses:**
- `201`: Client registered
- `400`: Invalid client metadata (RFC 7591 error body)
**Example:**
```bash
curl -X POST 'https://api.vennio.app/oauth/register'
```
---
### GET /oauth/authorize
**Authorization Endpoint (OAuth 2.1)**
Validates the authorization request (registered client, exact
redirect_uri match, `response_type=code`, PKCE S256 required) and
redirects the browser to the portal consent page at
`https://app.vennio.app/oauth/consent`. Invalid client/redirect_uri
renders a 400 (never redirects to an unverified URI); other errors
redirect back to the client with an OAuth error code.
Auth required: No
Docs: https://docs.vennio.app/api/oauth/oauthauthorize
**Parameters:**
- `client_id` (query, string) (required)
- `redirect_uri` (query, string) (required)
- `response_type` (query, string) (required)
- `code_challenge` (query, string) (required)
- `code_challenge_method` (query, string) (required)
- `state` (query, string)
- `scope` (query, string)
- `resource` (query, string)
**Responses:**
- `302`: Redirect to portal consent page (or back to client with error)
- `400`: Unknown client or unregistered redirect_uri
**Example:**
```bash
curl -X GET 'https://api.vennio.app/oauth/authorize?client_id=VALUE&redirect_uri=VALUE&response_type=VALUE&code_challenge=VALUE&code_challenge_method=VALUE'
```
---
### POST /oauth/consent/approve
**Approve OAuth Authorization (portal-internal)**
Called by the portal consent page with the user's Supabase JWT after
they approve the connection. Re-validates the client and redirect_uri,
mints a single-use authorization code (5-minute TTL), and returns the
client callback URL for the browser to follow.
Auth required: No
Docs: https://docs.vennio.app/api/oauth/approveoauthconsent
**Request Body:**
- `client_id`: string (required)
- `redirect_uri`: string (required)
- `code_challenge`: string (required)
- `code_challenge_method`: string (required)
- `state`: string
- `scope`: string
- `resource`: string
**Responses:**
- `200`: Authorization code minted
- `400`: Unknown client or unregistered redirect_uri
- `401`: Missing or invalid Supabase session
**Example:**
```bash
curl -X POST 'https://api.vennio.app/oauth/consent/approve'
```
---
### GET /oauth/clients/{client_id}
**Get Client Display Info (portal-internal)**
Returns what the portal consent page shows about a registered client:
its registered name, the hosts its redirect URIs point at, and
whether those hosts are verified. The
consent page reads these from here, never from its own query string,
so a crafted consent link cannot rename the client.
Auth required: No
Docs: https://docs.vennio.app/api/oauth/getoauthclientdisplay
**Parameters:**
- `client_id` (path, string) (required)
**Responses:**
- `200`: Client display info
- `404`: Unknown client_id
**Example:**
```bash
curl -X GET 'https://api.vennio.app/oauth/clients/YOUR_CLIENT_ID'
```
---
### POST /oauth/token
**Token Endpoint (OAuth 2.1)**
Exchanges an authorization code (with PKCE `code_verifier`) for an
access + refresh token pair, or rotates a refresh token. Accepts
`application/x-www-form-urlencoded` per the OAuth spec. Access tokens
are opaque (`vennio_at_...`, 1-hour TTL); refresh tokens
(`vennio_rt_...`, 30-day TTL) are single-use and rotated on redemption.
Auth required: No
Docs: https://docs.vennio.app/api/oauth/oauthtoken
**Request Body:**
- `grant_type`: string (required)
- `code`: string
- `redirect_uri`: string
- `client_id`: string
- `code_verifier`: string
- `refresh_token`: string
**Responses:**
- `200`: Token response
- `400`: OAuth error body (`invalid_grant`, `invalid_request`, `invalid_client`)
**Example:**
```bash
curl -X POST 'https://api.vennio.app/oauth/token'
```
---
### POST /oauth/revoke
**Token Revocation (RFC 7009)**
Revokes an access or refresh token. Always returns 200 for valid
requests regardless of whether the token existed (per RFC 7009).
Remote connectors call this on disconnect.
Auth required: No
Docs: https://docs.vennio.app/api/oauth/oauthrevoke
**Request Body:**
- `token`: string (required)
- `token_type_hint`: string
**Responses:**
- `200`: Token revoked (or did not exist)
**Example:**
```bash
curl -X POST 'https://api.vennio.app/oauth/revoke'
```
---
### GET /v1/me/connected-apps
**List Connected OAuth Apps**
Lists OAuth clients the authenticated user has authorized (remote MCP
connectors), with token status. Backs the portal "Connected apps"
management UI.
Auth required: No
Docs: https://docs.vennio.app/api/oauth/listconnectedapps
**Responses:**
- `200`: Connected apps
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/me/connected-apps'
```
---
### DELETE /v1/me/connected-apps
**Revoke a Connected OAuth App**
Revokes all tokens for one of the caller's connected OAuth clients.
Auth required: No
Docs: https://docs.vennio.app/api/oauth/revokeconnectedapp
**Parameters:**
- `client_id` (query, string) (required)
**Responses:**
- `200`: Tokens revoked
- `404`: No active tokens for that client
**Example:**
```bash
curl -X DELETE 'https://api.vennio.app/v1/me/connected-apps?client_id=VALUE'
```
---
## Platform APIs
Direct booking and availability APIs - core platform primitives.
No Venn Links required - pure programmatic access.
**Key Endpoints:**
- `POST /v1/bookings` - Create bookings directly
- `GET /v1/availability/slots` - Query available time slots (consent-aware)
- `GET /v1/availability/mutual` - Find mutual free time across calendars
- `GET /v1/availability/next` - Get next available slot
12 endpoints:
### GET /v1/bookings
**List Bookings**
List all bookings with optional filters.
Auth required: Yes
Docs: https://docs.vennio.app/api/platform-apis/listbookings
**Parameters:**
- `status` (query, string) — Filter by booking status
- `from` (query, string) — Filter bookings starting after this time (ISO 8601)
- `to` (query, string) — Filter bookings starting before this time (ISO 8601)
- `series_id` (query, string) — Filter by booking series ID (returns only occurrences from that series)
- `limit` (query, integer) — Maximum number of results (default 50, max 100)
- `cursor` (query, string) — Opaque cursor for pagination. Obtained from `pagination.next_cursor` in a previous response.
**Responses:**
- `200`: List of bookings
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `429`: Too many requests
```json
{
"type": "https://api.vennio.app/errors/rate-limit-exceeded",
"title": "Rate Limit Exceeded",
"status": 429,
"detail": "API rate limit exceeded",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/bookings' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/bookings
**Create Direct Booking**
Create a booking directly without requiring a Venn Link.
**Use Case**: Programmatic booking creation for platform integrations, AI agents,
or custom scheduling interfaces.
**What happens automatically**:
- Calendar event created on business calendar
- Confirmation emails sent to both parties
- Webhook fired (`booking.created` event)
- CRM sync triggered (if Salesforce/HubSpot connected)
Auth required: Yes
Docs: https://docs.vennio.app/api/platform-apis/createdirectbooking
**Parameters:**
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Request Body:**
- `business_id`: string — UUID of the business to book with. At least one of `business_id` or `principal_id` is required.
- `principal_id`: string — Alias for `business_id`. At least one is required; if both are present, `principal_id` takes precedence.
With a multi-host account, set this to any host principal your account manages to book against that host (delegated authority); the booking is attributed to that host and written to its calendar.
- `customer_id`: string — Optional UUID linking to an existing customer record
- `demo_mode`: boolean — When true, skips side effects (calendar events, confirmation emails, webhooks). Booking is created but marked as demo.
- `customer_email`: string (required)
- `customer_name`: string (required)
- `start_time`: string (required)
- `end_time`: string (required)
- `notes`: string
- `metadata`: object — Developer-controlled key-value pairs (max 50 keys).
Keys must be ≤40 characters, values must be strings ≤500 characters.
Reserved keys (test_mode, demo_mode, customer_calendar_consent,
booked_with_mutual_availability, price_amount, price_currency,
meet_link, booked_by) are rejected.
- `booking_answers`: object — Pre-validated answers to booking questions, keyed by question ID
- `recurrence`: object — Optional recurrence configuration for creating a recurring booking series
- `frequency`: string (required) — How often the booking repeats
- `count`: integer — Total number of occurrences (mutually exclusive with until_date)
- `until_date`: string — End date for the series (mutually exclusive with count, max 1 year)
- `timezone`: string — IANA timezone string (e.g., America/New_York). Used for DST-aware scheduling.
Example:
```json
{
"business_id": "0c2664e5-10be-4df3-86bb-14d1388b8a57",
"customer_email": "customer@example.com",
"customer_name": "Jane Doe",
"start_time": "2025-11-01T14:00:00Z",
"end_time": "2025-11-01T14:30:00Z",
"notes": "Looking forward to our meeting!"
}
```
**Responses:**
- `201`: Booking created successfully (single booking or recurring series)
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `409`: Resource conflict
```json
{
"type": "https://api.vennio.app/errors/conflict",
"title": "Conflict",
"status": 409,
"detail": "Resource already exists or conflicts with current state",
"request_id": "req_abc123"
}
```
- `422`: Idempotency-Key was already used with a different request body
- `429`: Too many requests
```json
{
"type": "https://api.vennio.app/errors/rate-limit-exceeded",
"title": "Rate Limit Exceeded",
"status": 429,
"detail": "API rate limit exceeded",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/bookings' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/bookings/{id}
**Get Booking Details**
Retrieve detailed information about a specific booking.
Auth required: Yes
Docs: https://docs.vennio.app/api/platform-apis/getbooking
**Parameters:**
- `id` (path, string) (required) — Booking ID
**Responses:**
- `200`: Booking details
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/bookings/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### PATCH /v1/bookings/{id}
**Update Booking**
Update booking details or reschedule.
**What happens automatically**:
- Calendar event updated
- Notification emails sent
- Webhook fired (`booking.updated` event)
Auth required: Yes
Docs: https://docs.vennio.app/api/platform-apis/updatebooking
**Parameters:**
- `id` (path, string) (required) — Booking ID
**Request Body:**
- `start_time`: string
- `end_time`: string
- `notes`: string
- `metadata`: object — Developer-controlled key-value pairs (max 50 keys).
Keys must be ≤40 characters, values must be strings ≤500 characters.
Reserved keys are rejected.
**Responses:**
- `200`: Booking updated
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
- `409`: Resource conflict
```json
{
"type": "https://api.vennio.app/errors/conflict",
"title": "Conflict",
"status": 409,
"detail": "Resource already exists or conflicts with current state",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X PATCH 'https://api.vennio.app/v1/bookings/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### DELETE /v1/bookings/{id}
**Cancel Booking**
Cancel a booking. Use `scope=series` to cancel all future confirmed occurrences in a recurring series.
**What happens automatically**:
- Calendar event deleted
- Cancellation emails sent
- Webhook fired (`booking.cancelled` event per occurrence, `booking_series.cancelled` for series)
Auth required: Yes
Docs: https://docs.vennio.app/api/platform-apis/cancelbooking
**Parameters:**
- `id` (path, string) (required) — Booking ID
- `scope` (query, string) — Cancellation scope. `single` (default) cancels only this booking. `series` cancels all future confirmed occurrences in the booking's series.
**Responses:**
- `200`: Booking cancelled (or series cancelled if scope=series)
- `400`: Bad request
```json
{
"type": "https://api.vennio.app/errors/bad-request",
"title": "Bad Request",
"status": 400,
"detail": "The request could not be processed",
"request_id": "req_abc123"
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X DELETE 'https://api.vennio.app/v1/bookings/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/bookings/stats
**Get Booking Statistics**
Get aggregate booking statistics for a business.
Auth required: Yes
Docs: https://docs.vennio.app/api/platform-apis/getbookingstats
**Parameters:**
- `from` (query, string) — Start of date range (ISO 8601)
- `to` (query, string) — End of date range (ISO 8601)
**Responses:**
- `200`: Booking statistics
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/bookings/stats' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/booking-series
**List Booking Series**
List recurring booking series for the authenticated business.
Auth required: Yes
Docs: https://docs.vennio.app/api/platform-apis/listbookingseries
**Parameters:**
- `status` (query, string) — Filter by series status
- `limit` (query, integer) — Max results per page
- `cursor` (query, string) — Pagination cursor
**Responses:**
- `200`: List of booking series
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/booking-series' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/booking-series/{id}
**Get Booking Series**
Get a specific booking series with all its occurrence bookings.
Auth required: Yes
Docs: https://docs.vennio.app/api/platform-apis/getbookingseries
**Parameters:**
- `id` (path, string) (required) — Booking series ID
**Responses:**
- `200`: Booking series with occurrences
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/booking-series/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/availability/slots
**Get Available Time Slots**
Get bookable time slots for a principal. Requires authentication.
**Direct mode**: The caller needs access to the principal: the caller
itself, a host the caller's account manages (see `/v1/hosts`), a principal
connected to the caller, or a grantor of an active consent to the caller
with `availability:read:freebusy` scope. Otherwise returns `403`, including
for principals that don't exist. No event details are included.
A publishable key reaches only the caller's own account and managed
hosts; connections, consents and `consent_id` mode need a secret key.
**Consent-scoped mode** (`consent_id`): Returns availability
scoped by the referenced consent's permissions. The authenticated user must be
the grantee on the consent, and the consent must carry
`availability:read:freebusy` or `availability:read:details`; a consent
holding only `booking:propose` returns `403`. Response includes a `consent`
audit trail. With `availability:read:details` scope, slots include event
titles and descriptions.
**Consent `time_range`**: when the consent that authorises the read
(either mode) has a `time_range`, `from` and `to` must both fall inside
it; otherwise the request returns `403` naming the permitted window.
Self, managed-host and connection access are not range-limited.
Auth required: Yes
Docs: https://docs.vennio.app/api/platform-apis/getavailabilityslots
**Parameters:**
- `business_id` (query, string) — Business UUID to check availability for. Also accepted as `principal_id` (alias).
- `principal_id` (query, string) — Alias for `business_id`. Send either (at least one required).
- `duration_minutes` (query, integer) — Meeting duration in minutes. Defaults to 30.
- `from` (query, string) (required) — Start of date range (ISO 8601)
- `to` (query, string) (required) — End of date range (ISO 8601)
- `timezone` (query, string) — Timezone for results (IANA format)
- `buffer` (query, integer) — Buffer time in minutes between bookings
- `group_by_day` (query, boolean) — Group slots by date
- `slot_interval` (query, integer) — Candidate slot cadence in minutes. Controls how far apart the start
times of returned slots are. Defaults to `min(duration_minutes, 15)`.
Values smaller than `duration_minutes` produce overlapping candidates
(caller picks one; the rest become moot once booked).
- `consent_id` (query, string) — Consent UUID. When provided, authentication is required and the
response is scoped by the consent's permissions. The authenticated
user must be the grantee on this consent, the consent must include
an availability read scope (`availability:read:freebusy` or
`availability:read:details`), and if the consent has a `time_range`
the `from`/`to` window must lie inside it. Otherwise `403`.
**Responses:**
- `200`: Available time slots
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/availability/slots?from=VALUE&to=VALUE' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/availability/next
**Get Next Available Slot**
Get the next available time slot for a business.
**Use Case**: Quick availability check - "When is the next opening?"
Requires authentication and the same access to the principal as
`GET /v1/availability/slots`, including its publishable-key limit.
Otherwise returns `403`.
Auth required: Yes
Docs: https://docs.vennio.app/api/platform-apis/getnextavailableslot
**Parameters:**
- `business_id` (query, string) — Business UUID. Also accepted as `principal_id` (alias).
- `principal_id` (query, string) — Alias for `business_id`. Send either (at least one required).
- `duration_minutes` (query, integer) — Meeting duration in minutes. Defaults to 30.
- `after` (query, string) — Find slots after this time (ISO 8601). Defaults to now.
- `days_ahead` (query, integer) — Number of days ahead to search
- `timezone` (query, string) — Timezone for results (IANA format)
- `slot_interval` (query, integer) — Candidate slot cadence in minutes. Defaults to `min(duration_minutes, 15)`.
**Responses:**
- `200`: Next available slot or no-availability response
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
- `429`: Rate limit exceeded
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/availability/next' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/availability/mutual
**Find Mutual Availability**
Find overlapping free time across 2-10 principals (calendar owners).
**Use Case**: Schedule a meeting with multiple attendees by finding times
when all participants are available.
The caller needs access to every principal in the list. Each principal
must be one of:
- the caller itself;
- a host the caller's account manages (see `/v1/hosts`) — no connection
or consent needed;
- connected to the caller (an active connection counts as consent);
- a grantor of an active consent to the caller with at least
`availability:read:freebusy` scope.
Otherwise the request returns `403`. Consent is held by the account, not
by individual hosts: a customer connected to the account can be queried
alongside any host that account manages. The response includes a
`consents` array for principals reached through explicit consent. A
consent with a `time_range` must contain the `from`/`to` window, or the
request returns `403`.
Requires a secret key or user session; publishable keys get `403`.
Auth required: Yes
Docs: https://docs.vennio.app/api/platform-apis/getmutualavailability
**Parameters:**
- `principal_ids` (query, array) (required) — Principal UUIDs whose availability will be intersected.
Pass as comma-separated: ?principal_ids=uuid1,uuid2,uuid3
- `duration_minutes` (query, integer) — Minimum meeting duration in minutes. Defaults to 30.
- `from` (query, string) (required) — Start of search window (ISO 8601)
- `to` (query, string) (required) — End of search window (ISO 8601)
- `timezone` (query, string) — Timezone for results (IANA format)
- `limit` (query, integer) — Maximum number of slots to return
- `slot_interval` (query, integer) — Candidate slot cadence in minutes. Defaults to `min(duration_minutes, 15)`.
Values smaller than `duration_minutes` produce overlapping candidates.
**Responses:**
- `200`: Mutual availability slots
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/availability/mutual?principal_ids=VALUE&from=VALUE&to=VALUE' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/principals/{principalId}/availability
**Get Principal Availability**
Get normalized availability blocks for a principal (calendar owner).
**Use Case**: Find mutual free time for scheduling across calendars.
Access: the caller itself, a host the caller's account manages (see
`/v1/hosts`), a principal connected to the caller, or a principal who has
granted the caller consent with `availability:read:freebusy` scope.
Otherwise returns `403`, including for principals that don't exist. Same
rule as `GET /v1/availability/slots`, including the consent `time_range`
check on `from`/`to`.
Requires a secret key or user session; publishable keys get `403`.
Auth required: Yes
Docs: https://docs.vennio.app/api/platform-apis/getprincipalavailability
**Parameters:**
- `principalId` (path, string) (required) — Principal (calendar owner) UUID
- `from` (query, string) — Start of date range (ISO 8601)
- `to` (query, string) — End of date range (ISO 8601)
- `granularity` (query, string) — Block granularity. Accepted but currently unused — reserved for future use.
**Responses:**
- `200`: Availability blocks
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/principals/YOUR_PRINCIPALID/availability' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
## Venn Links
Shareable booking links (like Calendly).
AI-powered availability matching with customizable booking pages.
8 endpoints:
### GET /v1/venn-links
**List Venn Links**
List all Venn Links for the authenticated business.
Auth required: Yes
Docs: https://docs.vennio.app/api/venn-links/listvennlinks
**Parameters:**
- `limit` (query, integer) — Maximum number of results (default 50, max 100)
- `cursor` (query, string) — Opaque cursor for pagination. Obtained from `pagination.next_cursor` in a previous response.
- `active` (query, boolean) — Filter by active status
**Responses:**
- `200`: List of Venn Links
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/venn-links' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/venn-links
**Create Venn Link**
Create a new shareable booking link.
Auth required: Yes
Docs: https://docs.vennio.app/api/venn-links/createvennlink
**Parameters:**
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Request Body:**
- `title`: string (required)
- `description`: string
- `duration_minutes`: integer (required)
- `venn_instructions`: string — Custom instructions for the AI scheduling assistant. Defaults to "Find mutually available times that work for both parties".
- `days_ahead_min`: integer — Earliest day from today that slots are offered.
- `days_ahead_max`: integer — Latest day from today that slots are offered. Must be >= days_ahead_min.
- `buffer_minutes`: integer — Buffer time in minutes between bookings.
- `max_bookings`: integer — Maximum number of bookings this link accepts. null = unlimited.
- `time_windows`: array — Inline weekly availability windows. null = use schedule or business hours.
- `schedule_id`: string — Optional schedule ID for availability hours
- `event_type_id`: string — Optional event type ID — link inherits settings from the event type
- `conference_enabled`: boolean — Enable Google Meet auto-link on bookings. null/true = enabled, false = disabled.
- `expires_at`: string — Expiry timestamp. After this time the link returns 403.
- `metadata`: object — Caller-defined arbitrary JSON metadata.
- `price_amount`: integer — Price in cents (e.g. 4500 = $45.00). null = inherit from event type, 0 = explicitly free.
- `price_currency`: string — ISO 4217 currency code (e.g. "usd").
- `booking_questions`: array — Custom fields to collect at booking time. null = inherit from event type, [] = no questions.
- `active`: boolean
**Responses:**
- `201`: Venn Link created
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `409`: A request with this Idempotency-Key is currently being processed
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/venn-links' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/venn-links/{identifier}
**Get Venn Link by Short Code**
Get a specific Venn Link by its short code.
**Public Endpoint**: No authentication required.
The `identifier` parameter should be the Venn Link's short code (e.g., "abc123").
Auth required: No
Docs: https://docs.vennio.app/api/venn-links/getvennlinkbyshortcode
**Parameters:**
- `identifier` (path, string) (required) — Venn Link short code (e.g., "abc123")
- `ref` (query, string) — Referral source for analytics tracking (e.g., "email", "badge")
**Responses:**
- `200`: Venn Link details
- `403`: Venn Link is inactive, expired, or has reached its booking limit
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/venn-links/YOUR_IDENTIFIER'
```
---
### PUT /v1/venn-links/{identifier}
**Update Venn Link**
Update a Venn Link's settings.
The `identifier` parameter should be the Venn Link's UUID.
Auth required: Yes
Docs: https://docs.vennio.app/api/venn-links/updatevennlink
**Parameters:**
- `identifier` (path, string) (required) — Venn Link ID (UUID)
**Request Body:**
- `title`: string
- `description`: string
- `duration_minutes`: integer
- `venn_instructions`: string
- `days_ahead_min`: integer
- `days_ahead_max`: integer
- `buffer_minutes`: integer
- `max_bookings`: integer
- `time_windows`: array
- `schedule_id`: string — Schedule ID (null to clear)
- `event_type_id`: string — Event type ID (null to clear)
- `conference_enabled`: boolean — Enable Google Meet auto-link on bookings (null to reset to default)
- `expires_at`: string
- `metadata`: object
- `price_amount`: integer — Price in cents (null to inherit from event type, 0 = explicitly free)
- `price_currency`: string — ISO 4217 currency code (null to inherit)
- `booking_questions`: array — Custom fields to collect at booking time. null = inherit from event type, [] = no questions.
- `active`: boolean
**Responses:**
- `200`: Venn Link updated
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X PUT 'https://api.vennio.app/v1/venn-links/YOUR_IDENTIFIER' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### DELETE /v1/venn-links/{identifier}
**Delete Venn Link**
Delete a Venn Link. Existing bookings are not affected.
The `identifier` parameter should be the Venn Link's UUID.
Auth required: Yes
Docs: https://docs.vennio.app/api/venn-links/deletevennlink
**Parameters:**
- `identifier` (path, string) (required) — Venn Link ID (UUID)
**Responses:**
- `204`: No Content
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X DELETE 'https://api.vennio.app/v1/venn-links/YOUR_IDENTIFIER' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/venn-links/{identifier}/availability
**Get Venn Link Availability**
Get available time slots for a Venn Link.
**Public Endpoint**: No authentication required.
**Mutual availability** — pass `customer_email` together with that
customer's own Supabase session (`Authorization: Bearer `). When
the session's email matches (case-insensitive) and the customer has
granted the business `availability:read:freebusy` consent, the
customer's busy times are removed from the slots and
`mutual_availability` is `true`. Without a matching session —
anonymous, API key, or a different user — `customer_email` is
ignored and the response is identical to a call without it.
Auth required: No
Docs: https://docs.vennio.app/api/venn-links/getvennlinkavailability
**Parameters:**
- `identifier` (path, string) (required) — Venn Link short code
- `customer_email` (query, string) — Customer email. Only honoured when the request also carries a
Supabase session for that customer; otherwise ignored.
**Responses:**
- `200`: Available time slots
- `403`: Venn Link is inactive, expired, or has reached its booking limit
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/venn-links/YOUR_IDENTIFIER/availability'
```
---
### POST /v1/venn-links/{identifier}/book
**Book via Venn Link**
Create a booking through a Venn Link.
**Public Endpoint**: No authentication required. The booking is
linked to a customer account (`customer_id`) only when the request
carries that customer's own Supabase session and its email matches
`customer_email`; otherwise the booking is stored by email alone.
**Free bookings** — confirmed immediately with side effects:
- Calendar event created
- Confirmation emails sent
- Webhooks fired
**Paid bookings** — returns `status: pending_payment` and `checkout_url`:
- Customer completes payment via Stripe Checkout
- On success, webhook confirms booking and fires side effects
Auth required: No
Docs: https://docs.vennio.app/api/venn-links/bookviavennlink
**Parameters:**
- `identifier` (path, string) (required) — Venn Link short code
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Request Body:**
- `customer_email`: string (required)
- `customer_name`: string (required)
- `start_time`: string (required)
- `notes`: string
- `success_url`: string — Override Stripe Checkout success redirect URL (paid bookings only)
- `cancel_url`: string — Override Stripe Checkout cancel redirect URL (paid bookings only)
- `answers`: object — Answers to booking questions, keyed by question ID
- `recurrence`: object — Optional recurrence configuration for creating a recurring booking series
- `frequency`: string (required) — How often the booking repeats
- `count`: integer — Total number of occurrences (mutually exclusive with until_date)
- `until_date`: string — End date for the series (mutually exclusive with count, max 1 year)
- `timezone`: string — IANA timezone string (e.g., America/New_York). Used for DST-aware scheduling.
**Responses:**
- `201`: Booking created (single or recurring series)
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `403`: Venn Link is inactive, expired, or has reached its booking limit
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
- `422`: Idempotency-Key was already used with a different request body
- `429`: Monthly booking limit reached for the business's subscription plan
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/venn-links/YOUR_IDENTIFIER/book'
```
---
### GET /v1/venn-links/{identifier}/bookings
**Get Venn Link Bookings**
Get all bookings made through a specific Venn Link.
Auth required: Yes
Docs: https://docs.vennio.app/api/venn-links/getvennlinkbookings
**Parameters:**
- `identifier` (path, string) (required) — Venn Link ID (UUID)
- `limit` (query, integer) — Maximum number of results (default 50, max 100)
- `cursor` (query, string) — Opaque cursor for pagination. Obtained from `pagination.next_cursor` in a previous response.
- `status` (query, string) — Filter bookings by status
**Responses:**
- `200`: List of bookings
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/venn-links/YOUR_IDENTIFIER/bookings' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
## Calendars
Connect and manage calendar integrations with multi-provider support.
**Supported Providers:**
- Google Calendar (fully supported)
- Microsoft 365 / Outlook (fully supported)
**Key Features:**
- OAuth-based secure connection
- Multi-provider simultaneous sync (Google + Microsoft together)
- Real-time webhooks for instant availability updates
- Configurable sync window (up to 365 days)
- Automatic token refresh
19 endpoints:
### GET /v1/calendars/connections
**Get Calendar Connection Health**
Returns health status for all active calendar connections.
Includes token expiry status, sync health, and write permission indicators.
Does not expose tokens or credentials.
Auth required: Yes
Docs: https://docs.vennio.app/api/calendars/getcalendarconnections
**Responses:**
- `200`: Connection health data
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/calendars/connections' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/calendars
**List Connected Calendars**
List all calendars for the authenticated user across all providers.
Auth required: Yes
Docs: https://docs.vennio.app/api/calendars/listcalendars
**Responses:**
- `200`: Calendars grouped by provider
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: No calendars connected
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/calendars' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/calendars/selection
**Get Calendar Selections**
Get which calendars are currently selected for availability sync.
Auth required: Yes
Docs: https://docs.vennio.app/api/calendars/getcalendarselection
**Responses:**
- `200`: Calendar selections
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/calendars/selection' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### PUT /v1/calendars/selection
**Save Calendar Selections**
Save which calendars should be used for availability sync.
Auth required: Yes
Docs: https://docs.vennio.app/api/calendars/savecalendarselection
**Request Body:**
- `calendar_ids`: array (required) — Array of calendar IDs to select
- `provider`: string (required) — Calendar provider to update selections for
**Responses:**
- `200`: Selections saved
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X PUT 'https://api.vennio.app/v1/calendars/selection' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/oauth/connect-token
**Mint Calendar Connect Token**
Create a single-use, short-lived token for initiating calendar OAuth.
The token binds the authenticated user's identity and optional redirect
URL into a server-side record. It connects the caller's own calendar
only. Pass the returned
token to `GET /v1/calendars/google/connect` or
`GET /v1/calendars/microsoft/connect` as the `token` query parameter.
Tokens expire after 5 minutes and are consumed on first use.
Auth required: Yes
Docs: https://docs.vennio.app/api/calendars/mintconnecttoken
**Request Body:**
- `provider`: string (required) — Calendar provider to connect
- `redirect_url`: string — Where to redirect after OAuth completion (must be HTTPS)
- `principal_id`: string — Removed 2026-09-28. Sending it returns `400 principal_id_not_supported`.
It let any caller create a connection to any principal. Connect a
customer to a business with an invitation or
`GET /v1/calendars/google/connect-guest`.
- `business_id`: string — Removed with `principal_id`; returns `400 principal_id_not_supported`.
**Responses:**
- `201`: Connect token created
- `400`: Invalid request (bad redirect_url, missing provider, or `principal_id_not_supported`)
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `429`: Rate limit exceeded (10 requests per minute per user)
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/oauth/connect-token' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/calendars/google/connect-guest-link
**Create Guest Connect Link (Google)**
Mint a single-use, 5-minute URL that lets one of your customers connect
their Google Calendar to your account without a Vennio account.
**Mint on click, redirect immediately.** Call this from your server when
the customer clicks "Connect calendar", then redirect their browser to
`connect_url`. The URL expires after 5 minutes and works once, so don't
email it or embed it in a page.
Your account is the business: it comes from the API key. `reference`
and `redirect_url` are bound into the link, so the customer can't change
them. `redirect_url` is checked against your redirect whitelist now, at
mint time.
Each call creates one guest principal for the customer. When they
finish Google sign-in, Vennio connects the guest to your account and
reports it the same way as `GET /v1/calendars/google/connect-guest`
(webhook `consent.granted`, plus `principal_id` and `reference` on the
redirect).
Auth required: Yes
Docs: https://docs.vennio.app/api/calendars/createguestconnectlink
**Request Body:**
- `redirect_url`: string — Where to send the customer after connecting. Must be `https:`
(or `http:` on localhost), have no fragment, and be a platform
domain or on your redirect URI whitelist.
- `reference`: string — Your own identifier for this customer or order (e.g. a trip
ID). Echoed back on the redirect and in the `consent.granted`
webhook. 1-128 characters: letters, digits, `.`, `_`, `:`, `-`.
**Responses:**
- `201`: Connect link created
- `400`: Invalid request: `invalid_redirect_url`, `redirect_url_not_whitelisted`,
`invalid_reference`, or `principal_id_not_supported` (the business is
the caller; `principal_id` and `business_id` are not accepted).
A body field of the wrong type returns a `ProblemDetail` validation error instead.
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: A publishable key was used (a secret key is required), or `not_a_business` (the caller is not a business account).
- `429`: Rate limit exceeded (10 requests per minute per account)
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/calendars/google/connect-guest-link' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/calendars/google/connect-guest
**Guest Calendar Connect (Google) — legacy**
> DEPRECATED
**Legacy.** Use `POST /v1/calendars/google/connect-guest-link`. This
form takes a raw `principal_id` in the URL, so anyone who knows the ID
can start a connect. It stays available only for embeds that existed
before the link endpoint; other businesses get `410 connect_guest_link_required`.
Creates a guest user and redirects to Google OAuth.
**Public Endpoint**: No authentication required.
**Use Case**: A business's end-customer connects their Google Calendar
without needing a Vennio account. The endpoint creates a throwaway guest
user, then internally mints a connect token and redirects through the
standard token-gated Google OAuth flow.
**Security**: `redirect_url` is validated against a hard-coded platform
domain allowlist and a per-business `business_redirect_uris` whitelist.
External URIs not on either list are rejected with 400.
**Result**: when the guest finishes, Vennio creates an active connection
between the guest and the business (the connection counts as consent for
availability reads) and reports the guest's principal ID two ways:
- **Webhook** `consent.granted` to the business, with `grantor_id` (the
guest), `grantee_id` (the business), `source: "calendar_connect"`,
`connection_id`, `consent_id`, `scopes` and your `reference`. Treat
this as the source of truth.
- **Redirect**: `redirect_url` gains `google_connected=true`,
`principal_id` and `reference`. The URL can be forged by anyone who
knows your redirect URL. Confirm the principal against the webhook or
`GET /v1/network/connections` before trusting it.
Auth required: No
Docs: https://docs.vennio.app/api/calendars/connectgooglecalendarguest
**Parameters:**
- `principal_id` (query, string) (required) — UUID of the business whose calendar the guest is connecting to. Also accepts `business_id` (alias).
- `business_id` (query, string) — Alias for `principal_id`. Send either, not both.
- `redirect_url` (query, string) — URL to redirect after OAuth completes. Must be `https:` (or `http:` on localhost), have no fragment, and be on the business's redirect URI whitelist. Other schemes return `400 invalid_redirect_url`.
- `reference` (query, string) — Your own identifier for this connect (e.g. a trip or user ID), echoed back
on the redirect and in the `consent.granted` webhook. Opaque to Vennio.
1-128 characters: letters, digits, `.`, `_`, `:`, `-`.
**Responses:**
- `302`: Redirect to Google OAuth consent screen
- `400`: Invalid request (missing principal_id, invalid_business when principal_id is not a lower- or upper-case v4 UUID of a business, invalid redirect_url, redirect_url not whitelisted, or invalid_reference)
- `410`: `connect_guest_link_required`: this business must use `POST /v1/calendars/google/connect-guest-link`.
- `429`: Rate limit exceeded (5 requests per minute or 20 per hour per IP, or 50 requests per hour per business). `Retry-After` gives the seconds until the window resets.
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/calendars/google/connect-guest?principal_id=VALUE'
```
---
### GET /v1/calendars/google/connect
**Initiate Google OAuth**
Redirect to Google OAuth consent screen.
**Public Endpoint** (token-gated): Browser redirect — cannot carry JWT,
so a short-lived connect token from `POST /v1/oauth/connect-token` is
required. The token binds the calling user's identity and optional metadata
(redirect URL, business ID) server-side.
Auth required: No
Docs: https://docs.vennio.app/api/calendars/connectgooglecalendar
**Parameters:**
- `token` (query, string) (required) — Single-use connect token from `POST /v1/oauth/connect-token`. Expires after 5 minutes.
**Responses:**
- `302`: Redirect to Google OAuth
- `400`: Missing, invalid, or expired connect token
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/calendars/google/connect?token=VALUE'
```
---
### GET /v1/calendars/google/callback
**Google OAuth Callback**
Handle Google OAuth callback and exchange code for tokens.
**Public Endpoint**: Called by Google after user authorization.
Auth required: No
Docs: https://docs.vennio.app/api/calendars/googleoauthcallback
**Parameters:**
- `code` (query, string) (required) — Authorization code from Google
- `state` (query, string) — State parameter for verification
**Responses:**
- `302`: Redirect to app with success/error status
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/calendars/google/callback?code=VALUE'
```
---
### POST /v1/calendars/google/link
**Link Google Calendar from sign-in tokens (unified auth)**
Store a Google calendar connection from tokens already obtained by the
app's sign-in grant (unified auth: one Google consent covers identity and
calendar). The connection and the selected primary calendar are set to the
token's own account, replacing any stale selection — which is what makes a
wrong-account attach impossible.
JWT user session only (an API key can't represent a user's Google grant).
Stores for the authenticated user.
Auth required: Yes
Docs: https://docs.vennio.app/api/calendars/linkgooglecalendar
**Request Body:**
- `access_token`: string (required) — Google access token from the sign-in session
- `refresh_token`: string — Google refresh token, if re-issued (omit to preserve a stored one)
- `expires_in`: integer — Seconds until the access token expires (default 3600)
**Responses:**
- `201`: Calendar linked
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: A user session is required (API keys cannot link a calendar).
- `422`: The token lacks `calendar.events` (can list calendars but not read events) —
e.g. an existing user's cached grant. Nothing was stored. The client should
re-run sign-in with `prompt=consent` to obtain the scope.
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/calendars/google/link' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/calendars/microsoft/link
**Link Microsoft (Outlook) Calendar from sign-in tokens (unified auth)**
Microsoft equivalent of `POST /v1/calendars/google/link`. Store a Microsoft
(Graph) calendar connection from tokens obtained by the app's Microsoft
sign-in grant (unified auth: one Azure consent covers identity and calendar).
The connection and selected primary calendar are set to the token's own
account, replacing any stale selection — making a wrong-account attach
impossible.
JWT user session only. Stores for the authenticated user.
Auth required: Yes
Docs: https://docs.vennio.app/api/calendars/linkmicrosoftcalendar
**Request Body:**
- `access_token`: string (required) — Microsoft Graph access token from the sign-in session
- `refresh_token`: string — Graph refresh token, if re-issued (omit to preserve a stored one)
- `expires_in`: integer — Seconds until the access token expires (default 3600)
**Responses:**
- `201`: Calendar linked
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: A user session is required (API keys cannot link a calendar).
- `422`: The token lacks the Calendars scope (cannot read the calendar) — e.g. an
existing user's cached grant. Nothing was stored. The client should re-run
sign-in with `prompt=consent` to obtain the scope.
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/calendars/microsoft/link' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/calendars/google/exchange
**Exchange Google Auth Code**
Exchange an authorization code and state nonce for tokens.
**Public Endpoint**: No auth header — the `state` nonce proves
the request originated from a legitimate OAuth flow initiated
via GET /v1/calendars/google/connect.
Auth required: No
Docs: https://docs.vennio.app/api/calendars/exchangegooglecode
**Request Body:**
- `code`: string (required) — Authorization code from Google
- `state`: string (required) — CSRF nonce from the OAuth flow (generated server-side by the connect endpoint)
**Responses:**
- `200`: Calendar connected successfully
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `403`: Calendar connection limit reached for current plan
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/calendars/google/exchange'
```
---
### POST /v1/calendars/apple/connect
**Connect Apple (iCloud) Calendar**
Connect an iCloud calendar via CalDAV. Apple has no OAuth — submit an
Apple ID and a 16-character **app-specific password** (created at
appleid.apple.com → Sign-In and Security → App-Specific Passwords).
The credential is validated against iCloud, then stored encrypted. Every
calendar on the account is selected for sync by default.
**Single active provider:** connecting Apple replaces the currently active
Google/Microsoft connection and clears its stale availability — the
`replaced` field reports what was deactivated so the UI can warn the user.
Auth required: Yes
Docs: https://docs.vennio.app/api/calendars/connectapplecalendar
**Request Body:**
- `apple_id`: string (required) — The iCloud account's Apple ID (usually an email).
- `app_password`: string (required) — A 16-character app-specific password (not the account password).
**Responses:**
- `200`: Apple calendar connected
- `401`: Invalid Apple credentials
- `422`: Authenticated but no event calendars on the account
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/calendars/apple/connect' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/calendars/microsoft/connect
**Initiate Microsoft OAuth**
Redirect to Microsoft OAuth consent screen for Microsoft 365 calendar access.
**Public Endpoint** (token-gated): Browser redirect — cannot carry JWT,
so a short-lived connect token from `POST /v1/oauth/connect-token` is
required. The token binds the calling user's identity and optional metadata
(redirect URL) server-side.
Auth required: No
Docs: https://docs.vennio.app/api/calendars/connectmicrosoftcalendar
**Parameters:**
- `token` (query, string) (required) — Single-use connect token from `POST /v1/oauth/connect-token`. Expires after 5 minutes.
**Responses:**
- `302`: Redirect to Microsoft OAuth
- `400`: Missing, invalid, or expired connect token
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/calendars/microsoft/connect?token=VALUE'
```
---
### GET /v1/calendars/microsoft/callback
**Microsoft OAuth Callback**
Handle Microsoft OAuth callback and exchange code for tokens.
**Public Endpoint**: Called by Microsoft after user authorization.
Auth required: No
Docs: https://docs.vennio.app/api/calendars/microsoftoauthcallback
**Parameters:**
- `code` (query, string) (required)
- `state` (query, string)
**Responses:**
- `302`: Redirect to app with success/error status
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/calendars/microsoft/callback?code=VALUE'
```
---
### POST /v1/calendars/setup-webhooks
**Setup Calendar Webhooks**
Register webhooks for real-time calendar sync with providers.
Auth required: Yes
Docs: https://docs.vennio.app/api/calendars/setupcalendarwebhooks
**Parameters:**
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Responses:**
- `200`: Webhooks registered for the user's connected provider
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: No calendar connected
- `409`: A request with this Idempotency-Key is currently being processed
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/calendars/setup-webhooks' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/calendars/webhook
**Calendar Webhook Receiver**
Receive webhook notifications from Google Calendar and Microsoft Graph.
**Public Endpoint**: Called by calendar providers.
Auth required: No
Docs: https://docs.vennio.app/api/calendars/receivecalendarwebhook
**Request Body:**
object
**Responses:**
- `200`: Webhook processed
- `202`: Webhook accepted for processing
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/calendars/webhook'
```
---
### POST /v1/calendars/sync
**Sync Calendars**
Manually trigger calendar sync for the authenticated user.
Syncs all connected providers by default, or a specific provider
via the `provider` query parameter.
Auth required: Yes
Docs: https://docs.vennio.app/api/calendars/synccalendars
**Parameters:**
- `provider` (query, string) — Sync only this provider (omit to sync all connected providers)
- `days_ahead` (query, integer) — Number of days ahead to sync
**Responses:**
- `200`: Sync completed
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: No active calendar connections found
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/calendars/sync' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### DELETE /v1/calendars/disconnect
**Disconnect Calendar Provider**
Disconnect a calendar provider and remove all associated data
(connections, selections, availability blocks, webhooks).
When `provider` is specified, disconnects only that provider.
When omitted, disconnects ALL connected providers.
Auth required: Yes
Docs: https://docs.vennio.app/api/calendars/disconnectcalendar
**Parameters:**
- `provider` (query, string) — Provider to disconnect. Omit to disconnect all providers.
**Responses:**
- `200`: Provider(s) disconnected
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: No calendar connection found for the specified provider (or no connections at all)
**Example:**
```bash
curl -X DELETE 'https://api.vennio.app/v1/calendars/disconnect' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
## Network
Manage your business network and stakeholder relationships.
## Invitations
Send and track invitations to contacts. Supports:
- Single invitation with personalized message
- Bulk import from HubSpot
- Status tracking (pending, viewed, accepted, declined, imported)
## Access Requests
Allow external parties to request calendar access.
Public endpoint - no auth required to create request.
## Relationship Types
Categorize contacts as: Client, Prospect, Investor, or Partner
21 endpoints:
### GET /v1/network
**Get Network Overview**
Returns a snapshot of the authenticated user's network including connection
counts, pending invitations, active consents, and recent connections.
Requires a secret key or user session; publishable keys get `403`.
Auth required: Yes
Docs: https://docs.vennio.app/api/network/getnetworkoverview
**Responses:**
- `200`: Network overview snapshot
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/network' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/network/connections
**List Network Connections**
Paginated list of the authenticated user's network connections with
optional filters for relationship type, status, and search.
Requires a secret key or user session; publishable keys get `403`.
Auth required: Yes
Docs: https://docs.vennio.app/api/network/listnetworkconnections
**Parameters:**
- `relationship_type` (query, string) — Filter by relationship type (e.g. client, vendor, partner)
- `status` (query, string) — Filter by connection status
- `search` (query, string) — Search connections by name or email
- `limit` (query, integer) — Maximum number of results (default 50, max 100)
- `cursor` (query, string) — Opaque cursor for pagination. Obtained from `pagination.next_cursor` in a previous response.
**Responses:**
- `200`: Paginated list of connections
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/network/connections' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### DELETE /v1/network/connections/{id}
**Disconnect**
End an active connection. Either party may call it. The connection is
set to `inactive` and kept for history; it no longer counts as consent
for availability reads. A `blocked` connection stays blocked. Use the
`id` from `GET /v1/network/connections`.
Auth required: Yes
Docs: https://docs.vennio.app/api/network/disconnectnetworkconnection
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `204`: Connection set to inactive
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: No active connection with this id involving the caller
**Example:**
```bash
curl -X DELETE 'https://api.vennio.app/v1/network/connections/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/network/mutual
**Find Mutual Connections**
Find connections shared between two or more principals. Returns profiles
that are connected to all specified principal IDs.
Requires a secret key or user session; publishable keys get `403`.
Auth required: Yes
Docs: https://docs.vennio.app/api/network/findmutualconnections
**Parameters:**
- `principal_ids` (query, string) (required) — Comma-separated list of principal IDs to find mutual connections between
- `limit` (query, integer) — Maximum number of results (default 50, max 100)
- `cursor` (query, string) — Opaque cursor for pagination. Obtained from `pagination.next_cursor` in a previous response.
**Responses:**
- `200`: Mutual connections result
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/network/mutual?principal_ids=VALUE' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/network/stats
**Get Network Stats**
Analytics for the authenticated user's network with optional date range
filtering. Includes connection, consent, invitation, and booking statistics.
Requires a secret key or user session; publishable keys get `403`.
Auth required: Yes
Docs: https://docs.vennio.app/api/network/getnetworkstats
**Parameters:**
- `from` (query, string) — Start of date range (ISO 8601). Defaults to all time.
- `to` (query, string) — End of date range (ISO 8601). Defaults to now.
**Responses:**
- `200`: Network statistics
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/network/stats' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/invitations
**List Invitations**
List sent invitations for the authenticated business with pagination.
Auth required: Yes
Docs: https://docs.vennio.app/api/network/listinvitations
**Parameters:**
- `status` (query, string) — Filter by invitation status
- `relationship_type` (query, string) — Filter by relationship type
- `limit` (query, integer) — Maximum number of results (default 50, max 100)
- `cursor` (query, string) — Opaque cursor for pagination. Obtained from `pagination.next_cursor` in a previous response.
**Responses:**
- `200`: List of invitations
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/invitations' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/invitations
**Send Invitation**
Create and send a stakeholder invitation.
Sends an email to the invitee with a link to accept/decline.
Auth required: Yes
Docs: https://docs.vennio.app/api/network/createinvitation
**Parameters:**
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Request Body:**
- `invitee_email`: string (required)
- `invitee_name`: string
- `relationship_type`: string
- `venn_link_id`: string
- `message`: string
**Responses:**
- `201`: Invitation created and sent
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `409`: Resource conflict
```json
{
"type": "https://api.vennio.app/errors/conflict",
"title": "Conflict",
"status": 409,
"detail": "Resource already exists or conflicts with current state",
"request_id": "req_abc123"
}
```
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/invitations' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/invitations/link
**Create Open Invitation Link**
Create an **open** (consumer) share link. Unlike `POST /v1/invitations`,
this takes no `invitee_email`: whoever opens the link and signs in with any
account accepts it, creating a mutual connection and bidirectional
`booking:propose` consent (first-acceptor-wins, single-use).
JWT user session only (an API key cannot represent human consent). Open to
any role. Vennio sends nothing — the caller shares the returned `path`
(e.g. `https://when.vennio.app{path}`). A fresh single-use token is minted
on every call.
Auth required: Yes
Docs: https://docs.vennio.app/api/network/createinvitationlink
**Request Body:**
- `message`: string
- `venn_link_id`: string
- `expires_in_days`: integer — Days until the link expires. Defaults to 30.
**Responses:**
- `201`: Open invitation link created
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: A user session is required (API keys cannot mint open links).
- `429`: Too many requests
```json
{
"type": "https://api.vennio.app/errors/rate-limit-exceeded",
"title": "Rate Limit Exceeded",
"status": 429,
"detail": "API rate limit exceeded",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/invitations/link' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/invitations/bulk
**Send Bulk Invitations**
Create multiple invitations at once.
Supports importing contacts without sending emails (`send_emails: false`).
Auth required: Yes
Docs: https://docs.vennio.app/api/network/createbulkinvitations
**Parameters:**
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Request Body:**
- `invitations`: array (required)
- `send_emails`: boolean — Whether to send invitation emails (false = import only)
Example:
```json
{
"invitations": [
{
"invitee_email": "john@example.com",
"invitee_name": "John Doe"
},
{
"invitee_email": "jane@example.com",
"invitee_name": "Jane Smith",
"relationship_type": "prospect"
}
],
"send_emails": false
}
```
**Responses:**
- `201`: Invitations created
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `409`: A request with this Idempotency-Key is currently being processed
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/invitations/bulk' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/invitations/{id}
**Get Invitation**
Get a specific invitation by ID.
Auth required: Yes
Docs: https://docs.vennio.app/api/network/getinvitation
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `200`: Invitation details
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/invitations/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### PATCH /v1/invitations/{id}
**Update Invitation**
Update an invitation (e.g., change relationship type).
Auth required: Yes
Docs: https://docs.vennio.app/api/network/updateinvitation
**Parameters:**
- `id` (path, string) (required)
**Request Body:**
- `relationship_type`: string
**Responses:**
- `200`: Invitation updated
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X PATCH 'https://api.vennio.app/v1/invitations/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### DELETE /v1/invitations/{id}
**Cancel Invitation**
Cancel a pending invitation or remove an imported contact.
Only works for `pending` or `imported` status.
Auth required: Yes
Docs: https://docs.vennio.app/api/network/cancelinvitation
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `204`: No Content
- `400`: Bad request
```json
{
"type": "https://api.vennio.app/errors/bad-request",
"title": "Bad Request",
"status": 400,
"detail": "The request could not be processed",
"request_id": "req_abc123"
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X DELETE 'https://api.vennio.app/v1/invitations/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/invitations/{id}/resend
**Resend Invitation Email**
Resend an invitation email.
For `imported` contacts, this sends the first invitation email and changes status to `pending`.
For `pending` contacts, this resends the invitation email.
Auth required: Yes
Docs: https://docs.vennio.app/api/network/resendinvitation
**Parameters:**
- `id` (path, string) (required)
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Responses:**
- `200`: Invitation email sent
- `400`: Bad request
```json
{
"type": "https://api.vennio.app/errors/bad-request",
"title": "Bad Request",
"status": 400,
"detail": "The request could not be processed",
"request_id": "req_abc123"
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
- `409`: A request with this Idempotency-Key is currently being processed
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/invitations/YOUR_ID/resend' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/invitations/accept/{token}
**Get Invitation Details (Public)**
Get invitation details by token for the acceptance page.
**Public Endpoint**: No authentication required.
Two response shapes by `invite_kind`:
- **targeted** (business invite): `invitee_email`, `relationship_type`,
`business`, `venn_link`.
- **open** (consumer share link): a slim `inviter.first_name` only (PII
minimised on this unauthenticated endpoint) — no email or business block.
Auth required: No
Docs: https://docs.vennio.app/api/network/getinvitationbytoken
**Parameters:**
- `token` (path, string) (required) — Invitation token from email link
**Responses:**
- `200`: Invitation details for the acceptance page
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/invitations/accept/YOUR_TOKEN'
```
---
### POST /v1/invitations/accept/{token}
**Accept Invitation**
Accept an invitation via token. Requires an authenticated user
session whose email address matches the invitation's `invitee_email`
(case-insensitive).
API keys (`vennio_sk_*`, `vennio_pk_*`) are not accepted on this
route — acceptance is a human consent action and must be performed
from a JWT-backed user session.
Auth required: Yes
Docs: https://docs.vennio.app/api/network/acceptinvitation
**Parameters:**
- `token` (path, string) (required)
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Responses:**
- `200`: Invitation accepted. Shape varies by `invite_kind`:
* **targeted** — `business.name`, `connect_calendar_url`.
* **open** — `connection_id` and `inviter.first_name` (the app routes
straight to the shared overlap). Both include `connection_id`.
- `400`: Bad request
```json
{
"type": "https://api.vennio.app/errors/bad-request",
"title": "Bad Request",
"status": 400,
"detail": "The request could not be processed",
"request_id": "req_abc123"
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Forbidden. The response body distinguishes the two failure modes:
* `detail: auth_method_invalid` — caller authenticated with an
API key. Acceptance requires a JWT user session.
* `detail: auth_email_mismatch` — caller's signed-in email does
not match the invitation's `invitee_email`. The body includes
both `invitee_email` and `signed_in_email` for the frontend
to render specific guidance.
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
- `409`: A request with this Idempotency-Key is currently being processed
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/invitations/accept/YOUR_TOKEN' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/invitations/decline/{token}
**Decline Invitation (Public)**
Decline an invitation via token.
**Public Endpoint**: No authentication required.
Auth required: No
Docs: https://docs.vennio.app/api/network/declineinvitation
**Parameters:**
- `token` (path, string) (required)
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Responses:**
- `204`: No Content
- `400`: Bad request
```json
{
"type": "https://api.vennio.app/errors/bad-request",
"title": "Bad Request",
"status": 400,
"detail": "The request could not be processed",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
- `409`: A request with this Idempotency-Key is currently being processed
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/invitations/decline/YOUR_TOKEN'
```
---
### GET /v1/access-requests
**List Access Requests**
List access requests for the authenticated user.
Auth required: Yes
Docs: https://docs.vennio.app/api/network/listaccessrequests
**Parameters:**
- `status` (query, string)
- `limit` (query, integer) — Maximum number of results (default 50, max 100)
- `cursor` (query, string) — Opaque cursor for pagination. Obtained from `pagination.next_cursor` in a previous response.
**Responses:**
- `200`: List of access requests
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/access-requests' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/access-requests
**Create Access Request**
Create a new calendar access request to a Vennio user.
**Public Endpoint**: No authentication required.
Allows external parties to request calendar access from a business user.
Auth required: No
Docs: https://docs.vennio.app/api/network/createaccessrequest
**Parameters:**
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Request Body:**
- `target_id`: string (required)
- `requester_email`: string (required)
- `requester_name`: string — Display name. Ignored on this unauthenticated endpoint: the request is stored with `requester_verified: false` and `requester_name: null`, because nobody has proved they own `requester_email`.
- `message`: string
- `requested_scopes`: array
Example:
```json
{
"target_id": "550e8400-e29b-41d4-a716-446655440000",
"requester_email": "john@example.com",
"requester_name": "John Doe",
"message": "I'd like to schedule a meeting to discuss the project",
"requested_scopes": [
"availability:read:freebusy",
"booking:propose"
]
}
```
**Responses:**
- `201`: Access request created
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
- `409`: A request with this Idempotency-Key is currently being processed
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/access-requests'
```
---
### GET /v1/access-requests/{id}
**Get Access Request**
Get a specific access request by ID.
Auth required: Yes
Docs: https://docs.vennio.app/api/network/getaccessrequest
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `200`: Access request details
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/access-requests/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### PATCH /v1/access-requests/{id}
**Respond to Access Request**
Approve or deny an access request.
Auth required: Yes
Docs: https://docs.vennio.app/api/network/respondtoaccessrequest
**Parameters:**
- `id` (path, string) (required)
**Request Body:**
- `action`: string (required)
**Responses:**
- `200`: Access request updated
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X PATCH 'https://api.vennio.app/v1/access-requests/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### DELETE /v1/access-requests/{id}
**Cancel Access Request**
Cancel a pending access request (requester only).
Auth required: Yes
Docs: https://docs.vennio.app/api/network/cancelaccessrequest
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `204`: No Content
- `400`: Bad request
```json
{
"type": "https://api.vennio.app/errors/bad-request",
"title": "Bad Request",
"status": 400,
"detail": "The request could not be processed",
"request_id": "req_abc123"
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X DELETE 'https://api.vennio.app/v1/access-requests/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
## HubSpot
HubSpot CRM integration for contact import and sync.
**Features:**
- OAuth-based connection
- Paginated contact listing with search
- Bulk import with progress tracking
- Duplicate detection
- Optional invitation emails
9 endpoints:
### GET /v1/hubspot/auth-url
**Get HubSpot OAuth URL**
Get the URL to initiate HubSpot OAuth connection.
Auth required: Yes
Docs: https://docs.vennio.app/api/hubspot/gethubspotauthurl
**Responses:**
- `200`: OAuth URL
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/hubspot/auth-url' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/hubspot/callback
**HubSpot OAuth Callback**
Handle HubSpot OAuth callback and exchange code for tokens.
Auth required: Yes
Docs: https://docs.vennio.app/api/hubspot/hubspotoauthcallback
**Request Body:**
- `code`: string (required)
**Responses:**
- `200`: Connection successful
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/hubspot/callback' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/hubspot/connection
**Get HubSpot Connection Status**
Get current HubSpot connection status.
Auth required: Yes
Docs: https://docs.vennio.app/api/hubspot/gethubspotconnection
**Responses:**
- `200`: Connection status
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/hubspot/connection' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### DELETE /v1/hubspot/connection
**Disconnect HubSpot**
Disconnect HubSpot integration.
Auth required: Yes
Docs: https://docs.vennio.app/api/hubspot/disconnecthubspot
**Responses:**
- `204`: No Content
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X DELETE 'https://api.vennio.app/v1/hubspot/connection' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/hubspot/contacts
**List HubSpot Contacts**
List contacts from connected HubSpot account with pagination.
Returns contacts with cursor-based pagination for large contact lists.
Auth required: Yes
Docs: https://docs.vennio.app/api/hubspot/listhubspotcontacts
**Parameters:**
- `limit` (query, integer) — Maximum number of results (default 50, max 100)
- `cursor` (query, string) — Opaque cursor for pagination. Obtained from `pagination.next_cursor` in a previous response.
**Responses:**
- `200`: List of contacts
- `400`: Bad request
```json
{
"type": "https://api.vennio.app/errors/bad-request",
"title": "Bad Request",
"status": 400,
"detail": "The request could not be processed",
"request_id": "req_abc123"
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/hubspot/contacts' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/hubspot/contacts/search
**Search HubSpot Contacts**
Search contacts in HubSpot by name or email.
Auth required: Yes
Docs: https://docs.vennio.app/api/hubspot/searchhubspotcontacts
**Parameters:**
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Request Body:**
- `query`: string (required) — Search query (name or email)
- `limit`: integer
- `cursor`: string — Opaque cursor for pagination
**Responses:**
- `200`: Search results
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `409`: A request with this Idempotency-Key is currently being processed
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/hubspot/contacts/search' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/hubspot/contacts/count
**Get HubSpot Contact Count**
Get the total number of contacts in the connected HubSpot account.
Auth required: Yes
Docs: https://docs.vennio.app/api/hubspot/gethubspotcontactcount
**Responses:**
- `200`: Contact count
- `400`: Bad request
```json
{
"type": "https://api.vennio.app/errors/bad-request",
"title": "Bad Request",
"status": 400,
"detail": "The request could not be processed",
"request_id": "req_abc123"
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/hubspot/contacts/count' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/hubspot/import-all
**Bulk Import HubSpot Contacts**
Import all contacts from the connected HubSpot account.
Processes synchronously and returns the final import result.
Use GET /import-jobs/{jobId} to retrieve the result later.
Auth required: Yes
Docs: https://docs.vennio.app/api/hubspot/starthubspotbulkimport
**Parameters:**
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Request Body:**
- `relationship_type`: string — Default relationship type for imported contacts
- `send_emails`: boolean — Whether to send invitation emails
Example:
```json
{
"relationship_type": "prospect",
"send_emails": false
}
```
**Responses:**
- `200`: Import completed
- `400`: Bad request
```json
{
"type": "https://api.vennio.app/errors/bad-request",
"title": "Bad Request",
"status": 400,
"detail": "The request could not be processed",
"request_id": "req_abc123"
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `409`: A request with this Idempotency-Key is currently being processed
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/hubspot/import-all' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/hubspot/import-jobs/{jobId}
**Get Import Job Status**
Get the status and progress of a bulk import job.
Auth required: Yes
Docs: https://docs.vennio.app/api/hubspot/gethubspotimportjobstatus
**Parameters:**
- `jobId` (path, string) (required) — Import job ID
**Responses:**
- `200`: Job status
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/hubspot/import-jobs/YOUR_JOBID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
## Salesforce
Salesforce CRM integration. **Deprecated** — this integration was an early
experiment and is not on the active roadmap. New integrations should use
webhooks and the public API rather than relying on these endpoints.
Planned for removal in a future release.
**Features:**
- OAuth-based connection
- Automatic event sync on booking
- Contact matching and creation
- Configurable sync settings
7 endpoints:
### GET /v1/salesforce/connect
**Initiate Salesforce OAuth**
> DEPRECATED
Redirect to Salesforce OAuth consent screen.
**Public Endpoint**: Initiates OAuth flow.
Auth required: No
Docs: https://docs.vennio.app/api/salesforce/connectsalesforce
**Parameters:**
- `state` (query, string) — State parameter for CSRF protection
**Responses:**
- `302`: Redirect to Salesforce OAuth
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/salesforce/connect'
```
---
### GET /v1/salesforce/callback
**Salesforce OAuth Callback**
> DEPRECATED
Handle Salesforce OAuth callback.
**Public Endpoint**: Called by Salesforce after user authorization.
Auth required: No
Docs: https://docs.vennio.app/api/salesforce/salesforceoauthcallback
**Parameters:**
- `code` (query, string) (required)
- `state` (query, string)
**Responses:**
- `302`: Redirect to app with success/error status
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/salesforce/callback?code=VALUE'
```
---
### GET /v1/salesforce/status
**Get Salesforce Connection Status**
> DEPRECATED
Get current Salesforce connection status and sync settings.
Auth required: Yes
Docs: https://docs.vennio.app/api/salesforce/getsalesforcestatus
**Responses:**
- `200`: Connection status
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/salesforce/status' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/salesforce/disconnect
**Disconnect Salesforce**
> DEPRECATED
Disconnect Salesforce integration.
Auth required: Yes
Docs: https://docs.vennio.app/api/salesforce/disconnectsalesforce
**Parameters:**
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Responses:**
- `204`: No Content
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `409`: A request with this Idempotency-Key is currently being processed
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/salesforce/disconnect' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### PATCH /v1/salesforce/settings
**Update Salesforce Settings**
> DEPRECATED
Update Salesforce sync settings.
Auth required: Yes
Docs: https://docs.vennio.app/api/salesforce/updatesalesforcesettings
**Request Body:**
- `sync_bookings`: boolean
- `create_contacts`: boolean
- `sync_events`: boolean
**Responses:**
- `200`: Settings updated
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X PATCH 'https://api.vennio.app/v1/salesforce/settings' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/salesforce/sync
**Sync All Bookings to Salesforce**
> DEPRECATED
Manually trigger sync of all recent bookings to Salesforce.
Auth required: Yes
Docs: https://docs.vennio.app/api/salesforce/syncalltosalesforce
**Parameters:**
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Responses:**
- `200`: Sync completed
- `400`: Bad request
```json
{
"type": "https://api.vennio.app/errors/bad-request",
"title": "Bad Request",
"status": 400,
"detail": "The request could not be processed",
"request_id": "req_abc123"
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `409`: A request with this Idempotency-Key is currently being processed
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/salesforce/sync' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/salesforce/sync/{bookingId}
**Sync Specific Booking to Salesforce**
> DEPRECATED
Sync a specific booking to Salesforce.
Auth required: Yes
Docs: https://docs.vennio.app/api/salesforce/syncbookingtosalesforce
**Parameters:**
- `bookingId` (path, string) (required)
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Responses:**
- `200`: Booking synced
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
- `409`: A request with this Idempotency-Key is currently being processed
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/salesforce/sync/YOUR_BOOKINGID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
## Schedules
Named weekly availability patterns with date-specific overrides.
**Features:**
- Reusable weekly hour templates (e.g. "Business Hours", "Summer Hours")
- Date-specific overrides for holidays, custom hours, or unavailable days
- Default schedule per user — used automatically by availability APIs
- Assignable to Venn Links via `schedule_id`
8 endpoints:
### GET /v1/schedules
**List Schedules**
List all schedules for the authenticated user.
Auth required: Yes
Docs: https://docs.vennio.app/api/schedules/listschedules
**Parameters:**
- `limit` (query, integer) — Maximum number of results (default 50, max 100)
- `cursor` (query, string) — Opaque cursor for pagination. Obtained from `pagination.next_cursor` in a previous response.
**Responses:**
- `200`: List of schedules
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/schedules' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/schedules
**Create Schedule**
Create a named weekly availability schedule. Automatically set as default if it's the user's first schedule.
Auth required: Yes
Docs: https://docs.vennio.app/api/schedules/createschedule
**Parameters:**
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Request Body:**
- `name`: string (required) — Human-readable schedule name
- `timezone`: string — IANA timezone
- `weekly_hours`: array
- `is_default`: boolean — Set as the default schedule for availability APIs
**Responses:**
- `201`: Schedule created
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `409`: A request with this Idempotency-Key is currently being processed
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/schedules' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/schedules/{id}
**Get Schedule**
Get a specific schedule by ID, including its overrides (up to 50, ordered by date).
Auth required: Yes
Docs: https://docs.vennio.app/api/schedules/getschedule
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `200`: Schedule details with overrides
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/schedules/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### PATCH /v1/schedules/{id}
**Update Schedule**
Partially update a schedule. If `is_default` is set to true, all other schedules for this user are unset.
Auth required: Yes
Docs: https://docs.vennio.app/api/schedules/updateschedule
**Parameters:**
- `id` (path, string) (required)
**Request Body:**
- `name`: string
- `timezone`: string
- `weekly_hours`: array
- `is_default`: boolean
**Responses:**
- `200`: Schedule updated
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X PATCH 'https://api.vennio.app/v1/schedules/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### DELETE /v1/schedules/{id}
**Delete Schedule**
Delete a schedule. Fails with 409 if any Venn Links reference it.
Auth required: Yes
Docs: https://docs.vennio.app/api/schedules/deleteschedule
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `204`: No Content
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
- `409`: Resource conflict
```json
{
"type": "https://api.vennio.app/errors/conflict",
"title": "Conflict",
"status": 409,
"detail": "Resource already exists or conflicts with current state",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X DELETE 'https://api.vennio.app/v1/schedules/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/schedules/{id}/overrides
**List Schedule Overrides**
List overrides for a schedule, with optional date range filters.
Auth required: Yes
Docs: https://docs.vennio.app/api/schedules/listscheduleoverrides
**Parameters:**
- `id` (path, string) (required)
- `from` (query, string) — Start date filter (YYYY-MM-DD)
- `to` (query, string) — End date filter (YYYY-MM-DD)
**Responses:**
- `200`: List of overrides
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/schedules/YOUR_ID/overrides' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/schedules/{id}/overrides
**Create Schedule Override**
Create or upsert a date-specific override. Set `time_windows` to null for an unavailable day (e.g. holiday).
Auth required: Yes
Docs: https://docs.vennio.app/api/schedules/createscheduleoverride
**Parameters:**
- `id` (path, string) (required)
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Request Body:**
- `date`: string (required) — The date to override (YYYY-MM-DD)
- `time_windows`: array — Available windows. null = unavailable all day (holiday).
**Responses:**
- `201`: Override created
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
- `409`: A request with this Idempotency-Key is currently being processed
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/schedules/YOUR_ID/overrides' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### DELETE /v1/schedules/{id}/overrides/{overrideId}
**Delete Schedule Override**
Delete a specific date override from a schedule.
Auth required: Yes
Docs: https://docs.vennio.app/api/schedules/deletescheduleoverride
**Parameters:**
- `id` (path, string) (required)
- `overrideId` (path, string) (required)
**Responses:**
- `204`: No Content
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X DELETE 'https://api.vennio.app/v1/schedules/YOUR_ID/overrides/YOUR_OVERRIDEID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
## Event Types
Reusable booking templates that Venn Links can inherit from.
**Features:**
- Define duration, buffer, schedule, and availability window once
- Assign to multiple Venn Links via `event_type_id`
- Live reference: changes auto-propagate to all linked Venn Links
- Link-level overrides: non-null fields on a Venn Link take precedence
- Auto-generated URL slugs from name
5 endpoints:
### GET /v1/event-types
**List Event Types**
List all event types for the authenticated user.
Auth required: Yes
Docs: https://docs.vennio.app/api/event-types/listeventtypes
**Parameters:**
- `limit` (query, integer) — Maximum number of results (default 50, max 100)
- `cursor` (query, string) — Opaque cursor for pagination. Obtained from `pagination.next_cursor` in a previous response.
- `active` (query, boolean) — Filter by active status
**Responses:**
- `200`: List of event types
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/event-types' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/event-types
**Create Event Type**
Create a reusable booking template. The slug is auto-generated from the name.
Auth required: Yes
Docs: https://docs.vennio.app/api/event-types/createeventtype
**Parameters:**
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Request Body:**
- `name`: string (required) — Human-readable name (slug auto-generated)
- `description`: string
- `duration_minutes`: integer
- `buffer_minutes`: integer
- `schedule_id`: string — Optional schedule for availability hours
- `days_ahead_min`: integer
- `days_ahead_max`: integer
- `color`: string — Hex color code
- `price_amount`: integer — Price in cents (e.g. 4500 = $45.00). null = free.
- `price_currency`: string — ISO 4217 currency code
- `booking_questions`: array — Custom fields to collect at booking time. null = no questions.
**Responses:**
- `201`: Event type created
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `409`: A request with this Idempotency-Key is currently being processed
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/event-types' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/event-types/{id}
**Get Event Type**
Get a specific event type by ID, including the count of linked Venn Links.
Auth required: Yes
Docs: https://docs.vennio.app/api/event-types/geteventtype
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `200`: Event type details
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/event-types/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### PATCH /v1/event-types/{id}
**Update Event Type**
Partially update an event type. If `name` changes and no explicit `slug` is provided, the slug is regenerated.
Auth required: Yes
Docs: https://docs.vennio.app/api/event-types/updateeventtype
**Parameters:**
- `id` (path, string) (required)
**Request Body:**
- `name`: string
- `slug`: string
- `description`: string
- `duration_minutes`: integer
- `buffer_minutes`: integer
- `schedule_id`: string — Schedule ID (null to clear)
- `days_ahead_min`: integer
- `days_ahead_max`: integer
- `color`: string
- `price_amount`: integer — Price in cents (null to clear)
- `price_currency`: string — ISO 4217 currency code
- `booking_questions`: array — Custom fields to collect at booking time. null to clear.
- `active`: boolean
**Responses:**
- `200`: Event type updated
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X PATCH 'https://api.vennio.app/v1/event-types/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### DELETE /v1/event-types/{id}
**Delete Event Type**
Delete an event type. Fails with 409 if any Venn Links reference it.
Auth required: Yes
Docs: https://docs.vennio.app/api/event-types/deleteeventtype
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `204`: No Content
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
- `409`: Resource conflict
```json
{
"type": "https://api.vennio.app/errors/conflict",
"title": "Conflict",
"status": 409,
"detail": "Resource already exists or conflicts with current state",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X DELETE 'https://api.vennio.app/v1/event-types/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
## Consents
Manage customer calendar access permissions.
GDPR-compliant consent tracking with granular scopes.
**Scopes:**
- `availability:read:freebusy` - See free/busy only
- `availability:read:details` - See full event details
- `booking:propose` - Create booking proposals
4 endpoints:
### GET /v1/consents
**List Consents**
List all consents granted by or to the authenticated user.
Auth required: Yes
Docs: https://docs.vennio.app/api/consents/listconsents
**Parameters:**
- `role` (query, string) — Filter by caller's role in the consent
- `active_only` (query, string) — Filter to active consents only (not revoked, not expired).
Defaults to `'true'` — callers must explicitly pass `false`
to include revoked and expired consents.
- `limit` (query, integer) — Maximum number of results (default 50, max 100)
- `cursor` (query, string) — Opaque cursor for pagination. Obtained from `pagination.next_cursor` in a previous response.
**Responses:**
- `200`: List of consents
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/consents' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/consents
**Create Consent**
Grant calendar access consent to a business.
Auth required: Yes
Docs: https://docs.vennio.app/api/consents/createconsent
**Parameters:**
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Request Body:**
- `grantee_id`: string (required)
- `scopes`: array (required)
- `expires_at`: string
- `time_range`: object — Optional time window the consent covers. Availability reads that
rely on this consent (`consent_id` mode, direct and mutual
availability) must request a `from`/`to` window inside it;
requests outside return `403`.
- `from`: string
- `to`: string
**Responses:**
- `201`: Consent created
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `409`: A request with this Idempotency-Key is currently being processed
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/consents' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/consents/{id}
**Get Consent Details**
Get a specific consent by ID.
Auth required: Yes
Docs: https://docs.vennio.app/api/consents/getconsent
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `200`: Consent details
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/consents/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### DELETE /v1/consents/{id}
**Revoke Consent**
Revoke a previously granted consent.
Auth required: Yes
Docs: https://docs.vennio.app/api/consents/revokeconsent
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `204`: No Content
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
- `409`: Resource conflict
```json
{
"type": "https://api.vennio.app/errors/conflict",
"title": "Conflict",
"status": 409,
"detail": "Resource already exists or conflicts with current state",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X DELETE 'https://api.vennio.app/v1/consents/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
## Proposals
Multi-party meeting proposals with threaded negotiation.
Supports multiple time slots, multiple participants, counter-proposals, and automatic expiration.
**Roles in a proposal:**
- **Organizer** — the principal whose API key created the proposal. Cannot accept, counter, or reject their own proposal.
- **Recipient** — each participant who needs to respond. Responds via a magic-link token, typically delivered in a proposal-invitation email. See `GET /v1/proposals/by-token` for token resolution, and the `token` body field on `POST /v1/proposals/{id}/response` for the response itself.
**Testing end-to-end with a single API key:** create the proposal with your key, then respond using the recipient's magic-link token (not the API key). The same key that created the proposal cannot respond to it — the API returns 403.
**Embedding in your app: use the widget, not raw fetches.** For browser contexts, embed the recipient-response flow with [`@vennio/proposals-widget`](https://www.npmjs.com/package/@vennio/proposals-widget) (2 lines of HTML) or [``](https://www.npmjs.com/package/@vennio/react) in React. The widget wraps `vennio.app/p/:token` in an iframe and surfaces accept/counter/reject as `postMessage` events. Your app never calls `POST /v1/proposals/{id}/response` directly — which means it can't accidentally call it as the organizer and hit `403 organizer_cannot_respond`. Use the raw REST endpoint only for server-side flows (e.g. webhooks, schedulers) where there is no browser context.
9 endpoints:
### GET /v1/proposals
**List Proposals**
List proposals where the authenticated user is a participant.
Auth required: Yes
Docs: https://docs.vennio.app/api/proposals/listproposals
**Parameters:**
- `status` (query, string)
- `thread_id` (query, string) — Filter by thread ID to get all proposals in a negotiation thread
- `limit` (query, integer) — Maximum number of results (default 50, max 100)
- `cursor` (query, string) — Opaque cursor for pagination. Obtained from `pagination.next_cursor` in a previous response.
**Responses:**
- `200`: List of proposals
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/proposals' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/proposals
**Create Proposal**
Create a new multi-party meeting proposal.
Sends proposed time slots to one or more participants who can accept, counter, or reject.
The authenticated user is automatically added as the organizer.
## Error codes
- `400 earliest_slot_too_soon` — **poll mode only.** The earliest proposed slot starts within the minimum poll voting window (~25 hours from now). Propose a later earliest slot, or switch to `mode: standard`.
- `403 consent_required` — at least one participant has not granted `booking:propose` to the originator. Establishing a network connection grants this automatically in both directions; if the participant has revoked it, the originator must wait for re-grant.
- `409 calendar_disconnected_originator` — the authenticated originator has no active calendar connection. Reconnect a calendar before creating proposals.
Auth required: Yes
Docs: https://docs.vennio.app/api/proposals/createproposal
**Parameters:**
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Request Body:**
- `mode`: string — Proposal shape. `standard` (default) for account-keyed proposals; `poll` for magic-link voting.
- `participant_ids`: array — UUIDs of Vennio accounts to invite (1-10). **Required for `standard` mode; forbidden for `poll` mode.**
- `participant_emails`: array — Email addresses of poll voters (1-10). **Only valid when `mode: poll`.** Non-empty produces a named poll (per-email magic links returned in `invitations[]`); empty/absent produces an open poll (single shareable `magic_link_url`).
- `slots`: array (required) — Proposed time slots (1-10)
- `message`: string
- `metadata`: object — Extensible metadata (service_type, duration_minutes, price, etc.)
- `expires_in_hours`: integer — Hours until the proposal expires
- `responder_base_url`: string — First-party app origin the responder magic-link should point at, e.g.
`https://when.vennio.app`. Standard mode only. Must be an allowed
first-party origin (exact-origin allowlist); a non-allowlisted value is
rejected `400`. When omitted, links default to the platform portal.
Only the origin is used — any path is discarded.
**Responses:**
- `201`: Proposal created. The response shape depends on `mode` and (for poll mode) whether `participant_emails` was supplied:
- **standard mode** → `StandardProposalCreated` — `{proposal}` only. Invitation emails are dispatched server-side; the caller does not receive magic-link URLs.
- **named poll** (`mode: poll` with non-empty `participant_emails`) → `NamedPollProposalCreated` — `{proposal, invitations: [...]}`. The caller owns voter messaging and gets one magic-link URL per named invitee.
- **open poll** (`mode: poll` with empty/absent `participant_emails`) → `OpenPollProposalCreated` — `{proposal, magic_link_url}`. A single shareable link; voters self-identify at vote time.
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `409`: A request with this Idempotency-Key is currently being processed
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/proposals' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/proposals/{id}
**Get Proposal Details**
Get a specific proposal by ID, including participants and slots.
Auth required: Yes
Docs: https://docs.vennio.app/api/proposals/getproposal
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `200`: Proposal details
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/proposals/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/proposals/{id}/response
**Respond to Proposal**
Accept, counter, or reject a proposal.
- **accept**: Requires `selected_slot_id`. Proposal transitions to `accepted` when ALL required participants accept.
- **counter**: Requires `counter_slots`. Current proposal transitions to `countered`, a new proposal is created in the same thread.
- **reject**: Any required participant rejecting transitions the whole proposal to `rejected`.
## For browser embeds: use the widget, not this endpoint
If you are embedding the recipient-response flow in a third-party app, use [`@vennio/proposals-widget`](https://www.npmjs.com/package/@vennio/proposals-widget) (vanilla JS, 2 lines of HTML) or [`` from `@vennio/react`](https://www.npmjs.com/package/@vennio/react). The widget embeds `vennio.app/p/:token` in an iframe and surfaces accept / counter / reject as `postMessage` events / React callbacks. Your app never calls this endpoint directly — which means it cannot accidentally call it with the organizer's API key and hit `403 organizer_cannot_respond`.
Call this endpoint directly only for server-side flows where there is no browser context (webhooks, schedulers, integration adapters).
## Authentication
Either a Bearer JWT / API key in the `Authorization` header, **or** a magic-link `token` in the request body. JWT wins when both are present (the token is left intact for later out-of-band use). When the body-token path is used, the token is consumed atomically with the status flip; if the saga rolls back, the token remains valid for retry.
**The proposal organizer cannot respond to their own proposal.** Responses come from recipients, not from the principal who created the proposal. To test end-to-end with a single API key, create the proposal with the key and then call this endpoint with the recipient's magic-link `token` in the body — your own Bearer auth on the response side will be rejected with 403.
## Error codes
- `400 poll_mode_only_supports_accept` — `mode='poll'` proposals only accept `action: accept` (vote). `counter` and `reject` are rejected.
- `401 missing_credentials` — no `Authorization` header and no `token` in the body. One of the two is required.
- `401 invalid_token` — body-token is malformed, expired, or already consumed
- `401 participant_unresolved` — the body-token authenticated but did not resolve to a participant row (open token still missing the self-identify fields the API expects, or named token with a deleted participant).
- `401 token_proposal_mismatch` — body-token is valid but belongs to a different proposal than `:id`
- `403 not_a_participant` — caller authenticated via JWT/API key but is not a participant of this proposal.
- `403 organizer_cannot_respond` — caller is the proposal's organizer. Organizers cannot respond to their own proposals; use the magic-link token path or call this endpoint as a different principal.
- `409 you_have_already_responded` — the resolved participant row has a non-`pending` response. Each participant gets one response; reconsideration is not supported.
- `409 consent_revoked` — the recipient revoked `booking:propose` consent between create and accept; proposal is expired
- `409 calendar_disconnected_recipient` — accepting recipient lost their calendar connection between create and accept; reconnect and retry
- `409 token_already_consumed` — the magic-link token was consumed by a concurrent request
- `400 participant_calendar_not_pinned` — at accept time, the responding participant has no calendar to write the event to. They must connect (or reconnect) a calendar and retry. Distinct from `409 calendar_disconnected_recipient` (create-time pre-flight); this fires when the participant connected post-create but lazy pin resolution still couldn't find a primary calendar.
- `429 rate_limited` — per-link rate limit exceeded. Open-poll shareable tokens are multi-use; the API applies a per-token rate limit (default 20 votes per 60 seconds, env-tunable via `OPEN_VOTE_RATE_LIMIT_MAX` / `OPEN_VOTE_RATE_LIMIT_WINDOW_MS`) as a basic anti-flood floor. Builders are responsible for stricter voter identity / dedup on open polls.
- `502 calendar_unavailable` — upstream calendar provider failed during event creation; saga rolled back, safe to retry
Auth required: Yes
Docs: https://docs.vennio.app/api/proposals/respondtoproposal
**Parameters:**
- `id` (path, string) (required)
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Request Body:**
- `action`: string (required)
- `selected_slot_id`: string — Required for accept action — ID of the slot to select
- `counter_slots`: array — Required for counter action
- `message`: string
- `token`: string — Magic-link token from a proposal-invitation email. Provide this **instead of** an `Authorization` header to authenticate as the participant the token was minted for. Ignored when a Bearer/API-key credential is also present.
- `voter_name`: string — **Open-poll only.** Self-supplied display name for the voter. Stored on the participant row created at vote time. Optional and never required — if both `voter_name` and `voter_email` are absent the row is recorded as `Anonymous`. Ignored for named-mode tokens (identity comes from the bound participant row).
- `voter_email`: string — **Open-poll only.** Self-supplied email for the voter. When present, the API deduplicates on `(proposal, lower(email))` — a second vote from the same email **revises** the prior vote (UPDATEs the existing row's `selected_slot_id`). When absent, every vote creates a new participant row (the no-identity floor — see endpoint description). Ignored for named-mode tokens.
**Responses:**
- `200`: Response recorded
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
- `409`: Resource conflict
```json
{
"type": "https://api.vennio.app/errors/conflict",
"title": "Conflict",
"status": 409,
"detail": "Resource already exists or conflicts with current state",
"request_id": "req_abc123"
}
```
- `422`: Idempotency-Key was already used with a different request body
- `429`: Too many requests
```json
{
"type": "https://api.vennio.app/errors/rate-limit-exceeded",
"title": "Rate Limit Exceeded",
"status": 429,
"detail": "API rate limit exceeded",
"request_id": "req_abc123"
}
```
- `502`: Upstream calendar provider unavailable. Safe to retry.
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/proposals/YOUR_ID/response' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/proposals/{id}/thread
**Get Proposal Thread**
Returns all proposals in the same negotiation thread, ordered chronologically.
Auth required: Yes
Docs: https://docs.vennio.app/api/proposals/getproposalthread
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `200`: Thread proposals
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/proposals/YOUR_ID/thread' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/proposals/{id}/cancel
**Cancel Proposal**
Cancel an `accepted` proposal. Both calendar events are deleted and the underlying booking transitions to `cancelled`. The other party receives a cancellation email naming the actor.
Only an existing participant can cancel. Cancellation is only valid from the `accepted` state — `proposed`, `countered`, `rejected`, `expired`, and already-`cancelled` proposals return 409.
Internally the saga moves through a transient `cancelling` status while calendar deletes are in flight; if any participant's calendar delete fails, the saga rolls back to `accepted` and returns `502 calendar_unavailable`.
## Error codes
- `403 forbidden` — caller is not a participant of this proposal
- `404 not_found` — proposal does not exist
- `409 conflict` — proposal is not in `accepted` state, or another cancel/accept is racing
- `502 calendar_unavailable` — upstream calendar provider failed during the delete phase; safe to retry
Auth required: Yes
Docs: https://docs.vennio.app/api/proposals/cancelproposal
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `200`: Proposal cancelled
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
- `409`: Resource conflict
```json
{
"type": "https://api.vennio.app/errors/conflict",
"title": "Conflict",
"status": 409,
"detail": "Resource already exists or conflicts with current state",
"request_id": "req_abc123"
}
```
- `502`: Upstream calendar provider unavailable. Safe to retry.
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/proposals/YOUR_ID/cancel' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/proposals/{id}/close
**Close Poll**
Organiser closes a poll-mode proposal. Resolves the poll if any votes have been cast, otherwise expires it. Poll-mode only — for standard proposals use `/cancel` (which has different semantics: it cancels an *accepted* proposal and deletes calendar events; close terminates a still-`proposed` poll).
Used for:
- **Open polls** — there is no all-voted trigger (the voter set is open-ended), so the organiser explicitly ends voting when they have what they need. Reaper expiry (`expires_at`) is the other resolution path.
- **Named polls** — early termination when the organiser doesn't want to wait for every named invitee.
## Outcomes
- **≥1 vote**: poll resolves. Winning slot = most votes (ties broken by earliest `start_time`). Proposal transitions to `accepted` with the winning slot's `is_selected: true`. A `poll_resolved` audit event is emitted, then a `status_changed(reason=organiser_close_with_votes)` event.
- **Zero votes**: proposal transitions to `expired`. `status_changed(reason=organiser_close_no_votes)` event. No winning slot.
## Error codes
- `401 authentication_required` — no Bearer/API-key credential
- `403 forbidden` — caller is not the proposal organiser (`requester_id`)
- `404 not_found` — proposal does not exist
- `409 conflict` — proposal is `standard` mode (use `/cancel`), or proposal status is not `proposed` (already terminal: `accepted`, `rejected`, `expired`, `cancelled`)
Auth required: Yes
Docs: https://docs.vennio.app/api/proposals/closepollproposal
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `200`: Poll closed (resolved or expired depending on vote count).
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
- `409`: Resource conflict
```json
{
"type": "https://api.vennio.app/errors/conflict",
"title": "Conflict",
"status": 409,
"detail": "Resource already exists or conflicts with current state",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/proposals/YOUR_ID/close' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/proposals/{id}/ics
**Download Resolved Poll as iCalendar**
Returns the resolved poll proposal as a `text/calendar` (.ics) file. Used by voters to add the agreed meeting to their calendar by tapping the magic-link URL with `?token=...` — no email required.
Token auth mirrors `GET /v1/proposals/by-token`: the magic-link token IS the credential, no `Authorization` header. Both named and open tokens are valid.
## Status guard
The proposal must be resolved (`status: 'accepted'`). Polls in any other state return `409 poll_not_resolved` — there is no winning slot to generate a calendar event from.
## iCal details
- Deterministic UID `proposal-{proposalId}-slot-{slotId}@vennio.app` — every retrieval of the same resolved poll produces the same UID, so calendar clients can reconcile organiser-side updates against a single shared event identity.
- `ORGANIZER` = the proposal organiser's profile.
- `ATTENDEE` = voters who voted (`selected_slot_id IS NOT NULL`) with a resolvable email. Anonymous voters and non-voting invitees are omitted — mirrors the Model 1 boundary: the API knows voters, the builder owns the silent non-voters.
- Title, description, and RSVP/PARTSTAT polish are **provisional**. The iCalendar content shape is validated against real calendar clients (Apple/Google/Outlook) in Job 2; expect refinements before GA.
## Error codes
- `400` — `token` query param missing
- `401 invalid_token` — token does not exist (malformed or already consumed for a named single-use token)
- `401 token_proposal_mismatch` — token is valid but for a different proposal
- `404 proposal_not_found` — proposal does not exist
- `404 winning_slot_not_found` — proposal is resolved but no slot is marked (corrupt state)
- `409 poll_not_resolved` — proposal is not in `accepted` status
- `410 token_expired` — token has passed its expiry
Auth required: No
Docs: https://docs.vennio.app/api/proposals/getproposalics
**Parameters:**
- `id` (path, string) (required)
- `token` (query, string) (required) — The raw magic-link token (named or open).
**Responses:**
- `200`: iCalendar (.ics) file for the resolved meeting.
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
- `409`: Resource conflict
```json
{
"type": "https://api.vennio.app/errors/conflict",
"title": "Conflict",
"status": 409,
"detail": "Resource already exists or conflicts with current state",
"request_id": "req_abc123"
}
```
- `410`: Token has expired.
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/proposals/YOUR_ID/ics?token=VALUE'
```
---
### GET /v1/proposals/by-token
**Get Proposal by Magic-Link Token**
Resolve a single-use magic-link token to its proposal. Used by the unauthenticated web pages a recipient lands on after clicking Accept/Decline in a proposal-invitation email.
No `Authorization` header is required — the token itself is the credential. The token is not consumed by this read; it is only consumed when the recipient submits an action via `POST /v1/proposals/{id}/response` with the `token` body field.
## Error codes
- `400` — `token` query parameter is missing
- `401 invalid_token` — token does not exist (malformed or already consumed for a named single-use token)
- `404 proposal_not_found` — token resolved but the referenced proposal (or bound participant row) has been deleted
- `410 token_expired` — token exists but has passed its `expires_at`
Auth required: No
Docs: https://docs.vennio.app/api/proposals/getproposalbytoken
**Parameters:**
- `token` (query, string) (required) — The raw magic-link token from the proposal-invitation email.
**Responses:**
- `200`: Proposal resolved from token.
**`participant` may be `null`** when the token is an open-poll shareable link (`token.kind: 'open'`). In that case the voter has not yet self-identified; client UI must render the name/email field and create the participant on submit via `POST /v1/proposals/{id}/response`. For named tokens (`token.kind: 'named'`), `participant` is always populated with the bound invitee's row.
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Token is invalid (malformed or already consumed).
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
- `410`: Token has expired.
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/proposals/by-token?token=VALUE'
```
---
## Teams
Persistent team scheduling with cross-organisational members.
**Member Types:**
- `vennio_user` — Full Vennio user, availability visible
- `external_consent` — External user with consent, availability visible
- `email_only` — No account, invited after acceptance (excluded from consensus)
**Key Features:**
- Mutual availability across all schedulable members
- Consensus-based proposals (all members must accept)
- Auto-booking with calendar events on proposal acceptance
- Up to 10 members per team
10 endpoints:
### GET /v1/teams
**List Teams**
List teams where the authenticated user is an owner or member.
Auth required: Yes
Docs: https://docs.vennio.app/api/teams/listteams
**Parameters:**
- `limit` (query, integer) — Maximum number of results (default 50, max 100)
- `cursor` (query, string) — Opaque cursor for pagination. Obtained from `pagination.next_cursor` in a previous response.
**Responses:**
- `200`: List of teams
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/teams' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/teams
**Create Team**
Create a new persistent team. The authenticated user becomes the team owner.
Optionally include initial members in the request body.
Auth required: Yes
Docs: https://docs.vennio.app/api/teams/createteam
**Parameters:**
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Request Body:**
- `name`: string (required)
- `description`: string
- `members`: array — Initial members to add. Each must have either `user_id` or both `email` and `name`.
**Responses:**
- `201`: Team created
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/teams' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/teams/{id}
**Get Team**
Get a team by ID, including all members. Accessible by owner or any member.
Auth required: Yes
Docs: https://docs.vennio.app/api/teams/getteam
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `200`: Team details
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/teams/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### PATCH /v1/teams/{id}
**Update Team**
Update team name or description. Owner only.
Auth required: Yes
Docs: https://docs.vennio.app/api/teams/updateteam
**Parameters:**
- `id` (path, string) (required)
**Request Body:**
- `name`: string
- `description`: string
**Responses:**
- `200`: Team updated
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X PATCH 'https://api.vennio.app/v1/teams/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### DELETE /v1/teams/{id}
**Delete Team**
Delete a team and all its members. Owner only. Cascades to team members.
Auth required: Yes
Docs: https://docs.vennio.app/api/teams/deleteteam
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `204`: Team deleted
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X DELETE 'https://api.vennio.app/v1/teams/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/teams/{id}/members
**Add Team Member**
Add a member to the team. Owner only.
Provide either `user_id` (for Vennio users) or `email` + `name` (for email-only members).
Member type is auto-classified based on consent status.
Auth required: Yes
Docs: https://docs.vennio.app/api/teams/addteammember
**Parameters:**
- `id` (path, string) (required)
**Request Body:**
- `user_id`: string — User ID of an existing Vennio user
- `email`: string — Email address (for email-only members)
- `name`: string — Display name (required for email-only members)
**Responses:**
- `201`: Member added
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/teams/YOUR_ID/members' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/teams/{id}/members/{memberId}
**Get Team Member**
Get details of a single team member.
Auth required: Yes
Docs: https://docs.vennio.app/api/teams/getteammember
**Parameters:**
- `id` (path, string) (required)
- `memberId` (path, string) (required)
**Responses:**
- `200`: Member details
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/teams/YOUR_ID/members/YOUR_MEMBERID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### DELETE /v1/teams/{id}/members/{memberId}
**Remove Team Member**
Remove a member from the team. Owner only. Cannot remove the owner.
Auth required: Yes
Docs: https://docs.vennio.app/api/teams/removeteammember
**Parameters:**
- `id` (path, string) (required)
- `memberId` (path, string) (required)
**Responses:**
- `204`: Member removed
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X DELETE 'https://api.vennio.app/v1/teams/YOUR_ID/members/YOUR_MEMBERID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/teams/{id}/availability
**Get Team Availability**
Find mutual availability across all schedulable team members (vennio_user + external_consent).
Email-only members are excluded from availability checks and listed in `skipped_members`.
Requires at least 2 schedulable members.
Requires a secret key or user session; publishable keys get `403`.
Auth required: Yes
Docs: https://docs.vennio.app/api/teams/getteamavailability
**Parameters:**
- `id` (path, string) (required)
- `duration_minutes` (query, integer)
- `from` (query, string) (required)
- `to` (query, string) (required)
- `timezone` (query, string)
- `limit` (query, integer)
**Responses:**
- `200`: Mutual availability slots
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/teams/YOUR_ID/availability?from=VALUE&to=VALUE' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/teams/{id}/proposals
**Create Team Proposal**
Create a consensus-based proposal from the team's schedulable members.
All schedulable members become proposal participants. Email-only members are excluded
from consensus and will be invited after acceptance.
Optionally provide `override_members` to use a custom participant roster.
Auth required: Yes
Docs: https://docs.vennio.app/api/teams/createteamproposal
**Parameters:**
- `id` (path, string) (required)
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Request Body:**
- `slots`: array (required)
- `message`: string
- `expires_in_hours`: integer
- `override_members`: array — Custom participant user_ids (overrides team roster)
**Responses:**
- `201`: Proposal created
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `403`: Authenticated but insufficient permissions
```json
{
"type": "https://api.vennio.app/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to access this resource",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/teams/YOUR_ID/proposals' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
## Hosts
Multi-host enterprise accounts. One account (one set of API keys) can
manage many independently-bookable host principals — e.g. each coach in
an L&D org. Each host is a full principal with its own connected calendar,
schedule, and bookings; bookings are attributed to the specific host.
The account's key carries **delegated authority**: it may query
availability for, and create/manage bookings against, any host it manages
(selected per-call via `principal_id`). Single-host accounts are
unaffected — the account is its own host.
Onboarding a host:
1. `POST /v1/hosts` — mint the host principal.
2. `POST /v1/hosts/{id}/connect-link` — get a hosted calendar-connect URL;
send it to the coach to connect their own calendar.
3. Query `GET /v1/availability/slots?principal_id={host}` and book with
`POST /v1/bookings` using that `principal_id`.
4 endpoints:
### GET /v1/hosts
**List Hosts**
List the hosts this account manages, with calendar-connection status.
Auth required: Yes
Docs: https://docs.vennio.app/api/hosts/listhosts
**Responses:**
- `200`: Hosts list
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/hosts' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/hosts
**Provision Host**
Create a new managed host principal under the authenticated account.
Returns the host's `principal_id`. Connect the host's calendar next via
`POST /v1/hosts/{id}/connect-link`.
Auth required: Yes
Docs: https://docs.vennio.app/api/hosts/createhost
**Request Body:**
- `display_name`: string (required) — The host's display name (e.g. the coach's name).
**Responses:**
- `201`: Host provisioned
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/hosts' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/hosts/{id}/connect-link
**Create Host Connect Link**
Return a single-use, 5-minute hosted Google calendar-connect URL for a
managed host. Send it to the coach; they connect their own calendar to
their own principal. The account must manage this host.
Auth required: Yes
Docs: https://docs.vennio.app/api/hosts/createhostconnectlink
**Parameters:**
- `id` (path, string) (required) — The host principal_id.
**Request Body:**
- `redirect_url`: string — Where to send the coach after connecting. Must be a platform
domain or whitelisted for this account.
**Responses:**
- `201`: Connect link generated
- `403`: Host is not managed by your account
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/hosts/YOUR_ID/connect-link' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### DELETE /v1/hosts/{id}
**Unlink Host**
Soft-unlink a host from this account (revokes delegated authority). The
host's profile and data are retained.
Auth required: Yes
Docs: https://docs.vennio.app/api/hosts/unlinkhost
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `204`: Host unlinked
- `404`: Host not found for this account
**Example:**
```bash
curl -X DELETE 'https://api.vennio.app/v1/hosts/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
## Offers
Direct appointment offers for immediate booking.
Email notifications sent automatically.
Recipients can accept or decline via link.
3 endpoints:
### GET /v1/offers
**List Offers**
> DEPRECATED
**Deprecated.** The legacy appointment-offers runtime was retired in Wave 6 of the proposals rebuild. The route is no longer mounted and returns 404. Use `GET /v1/proposals` instead.
---
List offers for the authenticated user (as business or customer).
Auth required: Yes
Docs: https://docs.vennio.app/api/offers/listoffers
**Parameters:**
- `status` (query, string)
- `role` (query, string) — Filter by caller's role in the offer
- `limit` (query, integer) — Maximum number of results (default 50, max 100)
- `cursor` (query, string) — Opaque cursor for pagination. Obtained from `pagination.next_cursor` in a previous response.
**Responses:**
- `200`: List of offers
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/offers' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/offers
**Create Offer**
> DEPRECATED
**Deprecated.** The legacy appointment-offers runtime was retired in Wave 6 of the proposals rebuild. The route is no longer mounted and returns 404. Use `POST /v1/proposals` instead — it's the system of record for two-party meeting negotiation.
---
Create a new appointment offer.
Sends an email to the customer with a specific time slot to accept or decline.
Auth required: Yes
Docs: https://docs.vennio.app/api/offers/createoffer
**Parameters:**
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Request Body:**
- `customer_id`: string (required)
- `proposed_start_time`: string (required)
- `proposed_end_time`: string (required)
- `message`: string
**Responses:**
- `201`: Offer created and email sent
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `409`: A request with this Idempotency-Key is currently being processed
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/offers' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/offers/{id}/respond
**Respond to Offer**
> DEPRECATED
**Deprecated.** The legacy appointment-offers runtime was retired in Wave 6 of the proposals rebuild. The route is no longer mounted and returns 404. Use `POST /v1/proposals/{id}/response` instead.
---
Accept or decline an offer.
Auth required: Yes
Docs: https://docs.vennio.app/api/offers/respondtooffer
**Parameters:**
- `id` (path, string) (required)
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Request Body:**
- `action`: string (required)
**Responses:**
- `200`: Response recorded
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
- `409`: A request with this Idempotency-Key is currently being processed
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/offers/YOUR_ID/respond' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
## Webhooks
Subscribe to real-time booking events with production-grade delivery guarantees.
## Event Types
- `booking.created` - New booking made
- `booking.updated` - Booking rescheduled
- `booking.cancelled` - Booking cancelled
- `consent.granted` - Customer granted calendar access
- `consent.revoked` - Customer revoked access
## Webhook Delivery
**Retry Schedule (Exponential Backoff):**
1. Immediate delivery attempt
2. Retry after 1 minute (if failed)
3. Retry after 5 minutes
4. **Maximum:** 3 retry attempts total
**Security:**
- HMAC-SHA256 signatures via `X-Webhook-Signature` header
- Secret provided at webhook creation (starts with `whsec_`)
8 endpoints:
### GET /v1/webhooks
**List Webhooks**
List all webhooks for the authenticated user.
Auth required: Yes
Docs: https://docs.vennio.app/api/webhooks/listwebhooks
**Parameters:**
- `limit` (query, integer) — Maximum number of results (default 50, max 100)
- `cursor` (query, string) — Opaque cursor for pagination. Obtained from `pagination.next_cursor` in a previous response.
**Responses:**
- `200`: List of webhooks
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/webhooks' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/webhooks
**Create Webhook**
Create a new webhook subscription.
Auth required: Yes
Docs: https://docs.vennio.app/api/webhooks/createwebhook
**Parameters:**
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Request Body:**
- `url`: string (required)
- `events`: array (required)
- `description`: string
- `consent_scope`: string — Optional consent scope for consent-gated delivery. When set, webhook delivery for events involving a data subject is gated on active consent.
**Responses:**
- `201`: Webhook created
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `409`: A request with this Idempotency-Key is currently being processed
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/webhooks' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/webhooks/{id}
**Get Webhook**
Get a specific webhook by ID.
Auth required: Yes
Docs: https://docs.vennio.app/api/webhooks/getwebhook
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `200`: Webhook details
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/webhooks/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### PUT /v1/webhooks/{id}
**Update Webhook**
Update a webhook's URL, events, or active status.
Auth required: Yes
Docs: https://docs.vennio.app/api/webhooks/updatewebhook
**Parameters:**
- `id` (path, string) (required)
**Request Body:**
- `url`: string
- `events`: array
- `active`: boolean
- `description`: string
- `consent_scope`: string — Optional consent scope for consent-gated delivery. Set to null to remove.
**Responses:**
- `200`: Webhook updated
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X PUT 'https://api.vennio.app/v1/webhooks/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### DELETE /v1/webhooks/{id}
**Delete Webhook**
Delete a webhook subscription.
Auth required: Yes
Docs: https://docs.vennio.app/api/webhooks/deletewebhook
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `204`: No Content
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X DELETE 'https://api.vennio.app/v1/webhooks/YOUR_ID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/webhooks/{id}/regenerate-secret
**Regenerate Webhook Secret**
Generate a new secret for webhook signature verification.
Auth required: Yes
Docs: https://docs.vennio.app/api/webhooks/regeneratewebhooksecret
**Parameters:**
- `id` (path, string) (required)
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Responses:**
- `200`: Secret regenerated
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
- `409`: A request with this Idempotency-Key is currently being processed
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/webhooks/YOUR_ID/regenerate-secret' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/webhooks/{id}/test
**Send Test Webhook**
Send a test `webhook.test` event to the webhook URL with a proper HMAC signature.
Use this to verify your endpoint is reachable and correctly validates signatures
without triggering a real event.
The test delivery appears in `GET /v1/webhooks/{id}/deliveries` so you can debug failures.
Auth required: Yes
Docs: https://docs.vennio.app/api/webhooks/sendtestwebhook
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `200`: Test delivery result
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/webhooks/YOUR_ID/test' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/webhooks/{id}/deliveries
**Get Webhook Deliveries**
Get delivery history for a webhook.
Auth required: Yes
Docs: https://docs.vennio.app/api/webhooks/getwebhookdeliveries
**Parameters:**
- `id` (path, string) (required)
- `status` (query, string) — Filter deliveries by status
- `limit` (query, integer) — Maximum number of results (default 50, max 100)
- `cursor` (query, string) — Opaque cursor for pagination. Obtained from `pagination.next_cursor` in a previous response.
**Responses:**
- `200`: Delivery history
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/webhooks/YOUR_ID/deliveries' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
## API Keys
Manage API keys for programmatic access.
Create, list, revoke, and monitor API keys. Each key has:
- Custom rate limits (1-10,000 requests/hour)
- Configurable scopes (read/write)
- Optional expiration dates
- Usage statistics and monitoring
**Security**: Keys are hashed with bcrypt before storage and only shown once at creation.
4 endpoints:
### GET /v1/api-keys
**List API Keys**
List all API keys for the authenticated user.
Auth required: Yes
Docs: https://docs.vennio.app/api/api-keys/listapikeys
**Responses:**
- `200`: List of API keys
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/api-keys' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/api-keys
**Create API Key**
Create a new API key for programmatic access.
**Important**: The full key is only shown once in the response. Store it securely.
Auth required: Yes
Docs: https://docs.vennio.app/api/api-keys/createapikey
**Parameters:**
- `Idempotency-Key` (header, string) — Unique key for idempotent POST requests (1-256 chars: alphanumeric, `_`, `-`, `:`, `.`).
Same key + same body within 24h replays the cached response.
**Request Body:**
- `name`: string (required)
- `description`: string
- `scopes`: array
- `rate_limit_per_hour`: integer
- `key_type`: string — Key type. Secret keys (`vennio_sk_*`) are for server-side use. Publishable keys (`vennio_pk_*`) are for client-side use and can be restricted to specific domains via `allowed_domains`.
- `allowed_domains`: array — Origin domains allowed to use this key (publishable keys only). Requests from unlisted origins are rejected. Supports exact match and subdomain matching (e.g. `example.com` allows `sub.example.com`).
- `expires_at`: string
- `environment`: string — Key environment. Test keys (vennio_sk_test_*) skip all side effects.
**Responses:**
- `201`: API key created
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `409`: A request with this Idempotency-Key is currently being processed
- `422`: Idempotency-Key was already used with a different request body
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/api-keys' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### DELETE /v1/api-keys/{keyId}
**Revoke API Key**
Revoke an API key. This action cannot be undone.
Auth required: Yes
Docs: https://docs.vennio.app/api/api-keys/revokeapikey
**Parameters:**
- `keyId` (path, string) (required)
**Request Body:**
- `reason`: string — Reason for revoking the key (stored for audit purposes)
**Responses:**
- `200`: Revoked API key audit data
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X DELETE 'https://api.vennio.app/v1/api-keys/YOUR_KEYID' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/api-keys/{keyId}/stats
**Get API Key Statistics**
Get usage statistics for an API key.
Auth required: Yes
Docs: https://docs.vennio.app/api/api-keys/getapikeystats
**Parameters:**
- `keyId` (path, string) (required)
- `days` (query, integer) — Number of days to include (default 7, max 30)
**Responses:**
- `200`: Usage statistics
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/api-keys/YOUR_KEYID/stats' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
## MCP
Model Context Protocol (MCP) endpoint for AI agent tool access.
Vennio implements the [MCP Streamable HTTP transport](https://modelcontextprotocol.io/docs/concepts/transports)
in stateless mode — each request is independent with no server-side session.
**Available tools:**
- `find_availability` — query bookable slots for a business
- `find_mutual_availability` — find overlapping free time across calendars
- `create_booking` — book a meeting programmatically
- `get_booking` / `cancel_booking` — manage existing bookings
- `propose_meeting` — create a multi-party time proposal
- `respond_to_proposal` / `get_proposal` — manage proposals
- `check_consent` — verify calendar access permissions
- `get_network` / `list_connections` / `find_mutual_connections` — network graph
**Authentication:** `Authorization: Bearer vennio_sk_live_...`
**Required headers:**
```
Content-Type: application/json
Accept: application/json, text/event-stream
```
1 endpoints:
### POST /mcp
**MCP JSON-RPC endpoint**
Accepts JSON-RPC 2.0 requests following the Model Context Protocol (MCP) Streamable HTTP transport spec.
Operates in stateless mode — no sessions, one server per request.
**Required headers:**
- `Authorization: Bearer vennio_sk_live_...`
- `Content-Type: application/json`
- `Accept: application/json, text/event-stream`
Auth required: Yes
Docs: https://docs.vennio.app/api/mcp/mcpjsonrpc
**Request Body:**
- `jsonrpc`: string (required)
- `id`: any
- `method`: string (required) — MCP method name (e.g. `tools/list`, `tools/call`)
- `params`: object — Method parameters (varies by method)
**Responses:**
- `200`: JSON-RPC response (or SSE stream for streaming methods)
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X POST 'https://api.vennio.app/mcp' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
## Widgets
Embeddable widgets for growth hacking and viral distribution.
## Email Signature Badges
Generate customizable SVG badges for email signatures.
Turn every email into a booking opportunity.
**Features:**
- 3 visual styles (minimal, professional, branded)
- Customizable colors and sizes
- Click tracking & attribution
- CDN-cached for performance
- No authentication required (public endpoint)
2 endpoints:
### GET /api/v1/badge/{shortCode}
**Get Email Signature Badge**
Generate an SVG badge for email signatures.
**Public Endpoint**: No authentication required.
Auth required: No
Docs: https://docs.vennio.app/api/widgets/getbadge
**Parameters:**
- `shortCode` (path, string) (required) — Venn Link short code
- `style` (query, string) — Badge style
- `color` (query, string) — Primary color (hex without
- `size` (query, string) — Badge size
- `theme` (query, string) — Colour theme
- `type` (query, string) — Badge action type
**Responses:**
- `200`: SVG badge
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/api/v1/badge/YOUR_SHORTCODE'
```
---
### GET /api/v1/badge/{shortCode}.svg
**Get Email Signature Badge (SVG extension)**
Generate an SVG badge for email signatures (with .svg extension).
**Public Endpoint**: No authentication required.
Auth required: No
Docs: https://docs.vennio.app/api/widgets/getbadgesvg
**Parameters:**
- `shortCode` (path, string) (required)
- `style` (query, string)
- `color` (query, string)
- `size` (query, string)
- `theme` (query, string)
- `type` (query, string)
**Responses:**
- `200`: SVG badge
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/api/v1/badge/YOUR_SHORTCODE.svg'
```
---
## Stripe
Stripe Connect integration for paid bookings.
**Features:**
- Connect onboarding (embedded component)
- Checkout Sessions for Venn Link bookings
- Webhook handling (payment confirmation)
- Payment records and refund tracking
6 endpoints:
### POST /v1/stripe/connect
**Create Stripe Connected Account**
Create a Stripe Express connected account for the authenticated user.
Idempotent — returns existing account if already connected.
Auth required: Yes
Docs: https://docs.vennio.app/api/stripe/createstripeconnect
**Responses:**
- `200`: Existing account returned
- `201`: New account created
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/stripe/connect' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/stripe/account-session
**Create Account Session**
Create a Stripe Account Session for embedded onboarding UI.
Auth required: Yes
Docs: https://docs.vennio.app/api/stripe/createstripeaccountsession
**Responses:**
- `200`: Account session created
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/stripe/account-session' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/stripe/account
**Get Stripe Account Status**
Get the connected Stripe account status and readiness.
Auth required: Yes
Docs: https://docs.vennio.app/api/stripe/getstripeaccount
**Responses:**
- `200`: Account status
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/stripe/account' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### DELETE /v1/stripe/disconnect
**Disconnect Stripe Account**
Remove the Stripe connection for the authenticated user.
Auth required: Yes
Docs: https://docs.vennio.app/api/stripe/disconnectstripe
**Responses:**
- `200`: Disconnected
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X DELETE 'https://api.vennio.app/v1/stripe/disconnect' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/stripe/webhook
**Stripe Webhook**
Receives Stripe webhook events (checkout.session.completed, account.updated).
Requires raw body for signature verification.
**Not for external use** — called by Stripe infrastructure.
Auth required: No
Docs: https://docs.vennio.app/api/stripe/handlestripewebhook
**Request Body:**
object
**Responses:**
- `200`: Event received
- `400`: Invalid signature
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/stripe/webhook'
```
---
### GET /v1/stripe/session-status
**Get Checkout Session Status**
Returns the payment status of a Stripe Checkout session.
**Public Endpoint**: No authentication required.
Used by the booking success page to verify payment completed after
Stripe Checkout redirect. Returns minimal data — no PII, no amounts.
Auth required: No
Docs: https://docs.vennio.app/api/stripe/getstripesessionstatus
**Parameters:**
- `session_id` (query, string) (required) — Stripe Checkout session ID (starts with `cs_`)
**Responses:**
- `200`: Session status
- `400`: Missing session_id parameter
- `404`: Session not found (invalid or expired session_id)
- `429`: Too many requests
```json
{
"type": "https://api.vennio.app/errors/rate-limit-exceeded",
"title": "Rate Limit Exceeded",
"status": 429,
"detail": "API rate limit exceeded",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/stripe/session-status?session_id=VALUE'
```
---
## Billing
Platform subscription management for Vennio developer plans.
**Note:** This is separate from `/v1/stripe/*` which handles Stripe Connect for marketplace payments.
These endpoints manage Vennio's own developer subscription tiers.
**Features:**
- Upgrade to Pro or Enterprise via Stripe Checkout
- Query current plan and API usage
- Manage subscription via Stripe Customer Portal
6 endpoints:
### POST /v1/billing/checkout
**Create Billing Checkout Session**
Creates a Stripe Checkout Session for upgrading to a paid developer plan.
Redirects the user to Stripe-hosted checkout on success.
Auth required: Yes
Docs: https://docs.vennio.app/api/billing/createbillingcheckout
**Request Body:**
- `plan`: string (required) — The developer plan to subscribe to
**Responses:**
- `200`: Checkout session URL
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/billing/checkout' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/billing/subscription
**Get Subscription Status**
Returns the authenticated user's current plan, subscription status, and API usage for the current month.
Auth required: Yes
Docs: https://docs.vennio.app/api/billing/getbillingsubscription
**Responses:**
- `200`: Subscription details
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/billing/subscription' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/billing/status
**Get Billing Status**
Returns the authenticated user's current plan details and subscription status.
Auth required: Yes
Docs: https://docs.vennio.app/api/billing/getbillingstatus
**Responses:**
- `200`: Current plan and subscription
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/billing/status' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/billing/usage
**Get Billing Usage**
Returns current-month booking count, active calendar connections, and plan limits.
Auth required: Yes
Docs: https://docs.vennio.app/api/billing/getbillingusage
**Responses:**
- `200`: Current usage metrics
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/billing/usage' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/billing/portal
**Create Customer Portal Session**
Creates a Stripe Customer Portal session for managing the subscription
(cancel, update payment method, view invoices).
Requires an existing paid subscription.
Auth required: Yes
Docs: https://docs.vennio.app/api/billing/createbillingportal
**Responses:**
- `200`: Customer portal URL
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
- `404`: No billing account found — user has not subscribed yet
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/billing/portal' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/billing/invoices
**List Invoices**
Returns the last 12 Stripe invoices for the authenticated user.
Includes invoice number, amount, status, and links to view or download the PDF.
Returns an empty array if the user has no billing account.
Auth required: Yes
Docs: https://docs.vennio.app/api/billing/listbillinginvoices
**Responses:**
- `200`: Invoice list
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/billing/invoices' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
## Audit Leads
Lead capture from the Scheduling Health Check audit tool at vennio.app/audit.
Stores audit results and sends personalised fix guide emails.
No authentication required — public endpoint.
1 endpoints:
### POST /v1/audit-leads
**Submit Audit Lead**
Captures a lead from the Scheduling Health Check tool.
Saves the audit results to the database and sends a personalised fix guide email.
**No authentication required** — this is a public endpoint called from vennio.app/audit.
Auth required: No
Docs: https://docs.vennio.app/api/audit-leads/createauditlead
**Request Body:**
- `email`: string (required) — Email address to send the fix guide to
- `score_letter`: string (required) — Letter grade for the audit score (e.g. "B+")
- `score_percent`: integer (required) — Numeric score from 0–100
- `top_risks`: array — List of top risk descriptions identified by the audit
- `answers`: object — Key-value map of question ID to answer value from the audit questionnaire.
Used to personalise the fix guide email.
**Responses:**
- `201`: Audit lead saved and fix guide email queued
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/audit-leads'
```
---
## Events
e-Venn-t — paid and free ticketed events with capacity limits, Stripe checkout,
and QR check-in tokens. Organiser endpoints require auth; public endpoints
(event lookup by short code, attendee registration) are unauthenticated and
rate-limited.
10 endpoints:
### GET /v1/events
**List own events**
Auth required: Yes
Docs: https://docs.vennio.app/api/events/get-v1-events
**Responses:**
- `200`: Events for the authenticated organiser
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/events' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/events
**Create a draft event**
Auth required: Yes
Docs: https://docs.vennio.app/api/events/post-v1-events
**Request Body:**
- `title`: string (required)
- `description`: string
- `location`: string
- `starts_at`: string (required)
- `ends_at`: string (required)
- `capacity`: integer (required)
- `is_free`: boolean
- `price_pence`: integer
**Responses:**
- `201`: Event created (status=draft)
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `401`: Authentication required or invalid
```json
{
"type": "https://api.vennio.app/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid authentication token",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/events' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/events/{id}
**Get an own event**
Auth required: Yes
Docs: https://docs.vennio.app/api/events/get-v1-events-id
**Responses:**
- `200`: Event found
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/events/{id}' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### PATCH /v1/events/{id}
**Update an event**
Auth required: Yes
Docs: https://docs.vennio.app/api/events/patch-v1-events-id
**Request Body:**
- `title`: string
- `description`: string
- `location`: string
- `starts_at`: string
- `ends_at`: string
- `capacity`: integer
- `is_free`: boolean
- `price_pence`: integer
**Responses:**
- `200`: Updated event
- `400`: Validation error
```json
{
"type": "https://api.vennio.app/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Invalid request parameters",
"request_id": "req_abc123",
"errors": {
"email": "Invalid email format"
}
}
```
- `409`: Resource conflict
```json
{
"type": "https://api.vennio.app/errors/conflict",
"title": "Conflict",
"status": 409,
"detail": "Resource already exists or conflicts with current state",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X PATCH 'https://api.vennio.app/v1/events/{id}' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### DELETE /v1/events/{id}
**Cancel an event (soft)**
Auth required: Yes
Docs: https://docs.vennio.app/api/events/delete-v1-events-id
**Responses:**
- `200`: Cancelled event
**Example:**
```bash
curl -X DELETE 'https://api.vennio.app/v1/events/{id}' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### POST /v1/events/{id}/publish
**Publish a draft event**
Auth required: Yes
Docs: https://docs.vennio.app/api/events/post-v1-events-id-publish
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `200`: Event published
- `409`: Resource conflict
```json
{
"type": "https://api.vennio.app/errors/conflict",
"title": "Conflict",
"status": 409,
"detail": "Resource already exists or conflicts with current state",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/events/YOUR_ID/publish' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/events/{id}/attendees
**List attendees for an event**
Auth required: Yes
Docs: https://docs.vennio.app/api/events/get-v1-events-id-attendees
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `200`: Attendees
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/events/YOUR_ID/attendees' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/events/{id}/attendees.csv
**CSV export of attendees**
Auth required: Yes
Docs: https://docs.vennio.app/api/events/get-v1-events-id-attendees-csv
**Parameters:**
- `id` (path, string) (required)
**Responses:**
- `200`: CSV download (capped at 10,000 rows)
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/events/YOUR_ID/attendees.csv' \
-H "Authorization: Bearer $VENNIO_API_KEY"
```
---
### GET /v1/events/public/{shortCode}
**Get a published event by short code (public, unauthenticated)**
Auth required: No
Docs: https://docs.vennio.app/api/events/get-v1-events-public-shortcode
**Parameters:**
- `shortCode` (path, string) (required)
**Responses:**
- `200`: Published event
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X GET 'https://api.vennio.app/v1/events/public/YOUR_SHORTCODE'
```
---
### POST /v1/events/{id}/register
**Register an attendee (public, rate-limited)**
Free events: returns the created attendee row.
Paid events: returns a Stripe Checkout URL; capacity is consumed
only when the webhook confirms payment.
Auth required: No
Docs: https://docs.vennio.app/api/events/post-v1-events-id-register
**Parameters:**
- `id` (path, string) (required)
**Request Body:**
- `name`: string (required)
- `email`: string (required)
**Responses:**
- `201`: Free → attendee created. Paid → checkout session created.
- `404`: Resource not found
```json
{
"type": "https://api.vennio.app/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource not found",
"request_id": "req_abc123"
}
```
- `409`: Resource conflict
```json
{
"type": "https://api.vennio.app/errors/conflict",
"title": "Conflict",
"status": 409,
"detail": "Resource already exists or conflicts with current state",
"request_id": "req_abc123"
}
```
- `429`: Too many requests
```json
{
"type": "https://api.vennio.app/errors/rate-limit-exceeded",
"title": "Rate Limit Exceeded",
"status": 429,
"detail": "API rate limit exceeded",
"request_id": "req_abc123"
}
```
**Example:**
```bash
curl -X POST 'https://api.vennio.app/v1/events/YOUR_ID/register'
```
---