Practical patterns for the three most common integration challenges: calendar sync, webhook handling, and timezone-aware scheduling.
Vennio reads and writes calendar events via connected Google or Microsoft calendars. The connection is per-user and uses OAuth 2.0.
To connect a user's calendar, redirect them to the Vennio OAuth initiation endpoint. Vennio handles the provider OAuth consent screen and exchanges tokens internally.
GET https://api.vennio.app/v1/calendars/google/connect?redirect_uri=https://yourapp.com/callback&state=USER_ID
Redirect your user to this URL. After they grant consent, Google redirects to Vennio's callback endpoint. Vennio exchanges the code for tokens internally, then redirects your user back to redirect_uri:
# Vennio receives the redirect from Google at:
GET https://api.vennio.app/v1/calendars/google/callback?code=AUTH_CODE&state=STATE_TOKEN
# No action required — Vennio exchanges the token, stores the connection,
# and redirects the user to your redirect_uri with a success flag:
https://yourapp.com/callback?google_connected=true
For Microsoft calendars, use /v1/calendars/microsoft/connect and /v1/calendars/microsoft/callback instead.
After connection, list calendars to confirm what's synced and which are selected for availability:
curl https://api.vennio.app/v1/calendars \
-H "Authorization: Bearer $VENNIO_API_KEY"
{
"calendars": [
{
"id": "cal_abc123",
"name": "Work",
"provider": "google",
"primary": true,
"selected": true
},
{
"id": "cal_def456",
"name": "Personal",
"provider": "google",
"primary": false,
"selected": false
}
],
"providers": [
{ "name": "google", "connected": true, "calendar_count": 2 }
]
}
Vennio syncs calendars automatically on a schedule. Trigger a manual sync when you need up-to-date availability immediately:
curl -X POST https://api.vennio.app/v1/calendars/sync \
-H "Authorization: Bearer $VENNIO_API_KEY"
{
"events_synced": 24,
"last_sync": "2026-04-11T10:05:00Z"
}
Automatic syncs run every 15 minutes. Manual sync is throttled — if called within 60 seconds of the last sync, Vennio returns the cached last_sync time instead of triggering again.
curl -X DELETE "https://api.vennio.app/v1/calendars/disconnect?provider=google" \
-H "Authorization: Bearer $VENNIO_API_KEY"
This removes all tokens and stops syncing for that provider.
Webhooks let your app react to booking events in real time — no polling required.
curl -X POST https://api.vennio.app/v1/webhooks \
-H "Authorization: Bearer $VENNIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/webhooks/vennio",
"events": ["booking.created", "booking.cancelled", "booking.updated"],
"description": "Production booking events"
}'
{
"id": "wh_abc123",
"url": "https://yourapp.com/webhooks/vennio",
"events": ["booking.created", "booking.cancelled", "booking.updated"],
"secret": "whsec_xxxxxxxxxxxxxxxxxxxx",
"created_at": "2026-04-11T10:00:00Z"
}
The webhook signing secret is shown once. Store it as VENNIO_WEBHOOK_SECRET in your environment. You cannot retrieve it after creation — only rotate it.
Every delivery includes a X-Webhook-Signature header containing an HMAC-SHA256 of the raw request body. Always verify before processing.
import crypto from 'crypto'
import express from 'express'
const app = express()
// IMPORTANT: Use raw body, not parsed JSON
app.post('/webhooks/vennio', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-webhook-signature']
const secret = process.env.VENNIO_WEBHOOK_SECRET
const expected = crypto
.createHmac('sha256', secret)
.update(req.body)
.digest('hex')
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).json({ error: 'Invalid signature' })
}
const event = JSON.parse(req.body)
switch (event.type) {
case 'booking.created':
console.log('New booking:', event.data.booking.id)
// Provision meeting room, notify team, update CRM...
break
case 'booking.cancelled':
console.log('Booking cancelled:', event.data.booking.id)
break
case 'booking.updated':
console.log('Booking rescheduled:', event.data.booking.id)
break
}
res.json({ received: true })
})
All events share a consistent envelope:
{
"id": "evt_abc123",
"type": "booking.created",
"created_at": "2026-04-11T10:00:00Z",
"data": {
"booking": {
"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"
}
}
}
| Event | Trigger |
|---|---|
booking.created |
A booking was made |
booking.updated |
A booking was rescheduled |
booking.cancelled |
A booking was cancelled |
consent.granted |
A user granted calendar access |
consent.revoked |
A user revoked calendar access |
Vennio retries failed deliveries (non-2xx or timeout) with exponential backoff:
| Attempt | Delay |
|---|---|
| 1st retry | 1 minute |
| 2nd retry | 5 minutes |
| 3rd retry | 30 minutes |
| Final | Marked failed after 4 total attempts |
View delivery history at GET /v1/webhooks/{id}/deliveries.
Use ngrok or Cloudflare Tunnel to expose a local endpoint during development:
# Start your local handler on port 3000
node webhook-handler.js
# In another terminal, expose it
ngrok http 3000
# Register the ngrok URL as your webhook endpoint
curl -X POST https://api.vennio.app/v1/webhooks \
-H "Authorization: Bearer $VENNIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://abc123.ngrok.io/webhooks/vennio", "events": ["booking.created"]}'
All times in the Vennio API are ISO 8601 strings. Times stored and returned are always in UTC. Timezone conversion is your responsibility at the presentation layer.
When creating a booking, send start_time and end_time in UTC:
# Correct — UTC
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-05-01T14:00:00Z",
"end_time": "2026-05-01T14:30:00Z"
}'
The availability endpoint accepts a timezone parameter. Returned slots use the local timezone offset for display, but the underlying times are always UTC-equivalent:
curl "https://api.vennio.app/v1/availability/slots?business_id=YOUR_BUSINESS_ID&duration_minutes=30&timezone=America%2FNew_York" \
-H "Authorization: Bearer $VENNIO_API_KEY"
{
"slots": [
{ "start": "2026-05-01T09:00:00-04:00", "end": "2026-05-01T09:30:00-04:00" },
{ "start": "2026-05-01T10:00:00-04:00", "end": "2026-05-01T10:30:00-04:00" }
]
}
You can pass the returned start and end values directly to POST /v1/bookings — the offset is preserved.
// Using the Temporal API (Node.js 18.14+ / polyfill required in older versions)
import { Temporal } from '@js-temporal/polyfill'
function toUtcString(localDateTimeString, ianaTimezone) {
const zdt = Temporal.ZonedDateTime.from(`${localDateTimeString}[${ianaTimezone}]`)
return zdt.toInstant().toString() // "2026-05-01T14:00:00Z"
}
const startUtc = toUtcString('2026-05-01T10:00:00', 'America/New_York')
// → "2026-05-01T14:00:00Z"
// Using date-fns-tz (stable alternative)
import { zonedTimeToUtc } from 'date-fns-tz'
const startUtc = zonedTimeToUtc('2026-05-01 10:00:00', 'America/New_York').toISOString()
// → "2026-05-01T14:00:00.000Z"
Always store and compare times in UTC. Convert to the user's local timezone only when displaying:
import { formatInTimeZone } from 'date-fns-tz'
function displayBookingTime(utcString, userTimezone) {
return formatInTimeZone(new Date(utcString), userTimezone, 'MMM d, yyyy h:mm a zzz')
}
displayBookingTime('2026-05-01T14:00:00Z', 'America/New_York')
// → "May 1, 2026 10:00 AM EDT"
displayBookingTime('2026-05-01T14:00:00Z', 'Europe/London')
// → "May 1, 2026 3:00 PM BST"
date-fns-tz is a reliable, tree-shakeable option for timezone conversion. The native Temporal API is available in Node.js 18.14+ via the @js-temporal/polyfill package and is preferred for greenfield projects.