Overview
Webhooks allow your platform to receive real-time notifications when important events happen:
- User connects their calendar
- Meeting is scheduled
- Meeting is rescheduled
- Meeting is cancelled
- User updates preferences
Setup
Configure your webhook URL in the platform settings or via API:
PUT https://api.syncline.run/v1/platform/webhook
Request Body
{
"url": "https://your-platform.com/webhooks/syncline",
"enabled": true,
"events": [
"user.calendar_connected",
"meeting.scheduled",
"meeting.rescheduled",
"meeting.cancelled"
],
"max_retries": 3,
"timeout_seconds": 30
}
Webhook Events
Syncline supports 10 webhook event types across 3 categories: User Calendar, Meeting, and Platform events.
Event Payload Structure
All webhook events follow this consistent structure:
{
"id": "wh_abc123",
"event_type": "meeting.scheduled",
"timestamp": "2025-11-21T14:30:00Z",
"platform_id": "plat_xyz789",
"api_version": "v1",
"data": {
// Event-specific data
}
}
User Calendar Events
user.calendar.connected
Fired when a user successfully connects their Google Calendar via OAuth.
Multi-Platform Behavior: If the user is connected to multiple platforms (e.g., Boardy, Luma, Cal.com), ALL platforms receive this webhook.
{
"id": "wh_abc123",
"event_type": "user.calendar.connected",
"timestamp": "2025-11-21T14:30:00Z",
"platform_id": "plat_xyz789",
"api_version": "v1",
"data": {
"user_email": "alice@example.com",
"google_id": "105942374304",
"connected_at": "2025-11-21T14:30:00Z",
"timezone": "America/Los_Angeles",
"preferences": {
"timezone": "America/Los_Angeles",
"working_hours_start": 9,
"working_hours_end": 17
},
"calendar_access": "read_write"
}
}
user.calendar.disconnected
Fired when a user revokes calendar access or connection is deleted.
Multi-Platform Behavior: ALL platforms that had this user connected receive this webhook.
{
"id": "wh_def456",
"event_type": "user.calendar.disconnected",
"timestamp": "2025-11-21T15:45:00Z",
"platform_id": "plat_xyz789",
"api_version": "v1",
"data": {
"user_email": "alice@example.com",
"google_id": "105942374304",
"disconnected_at": "2025-11-21T15:45:00Z",
"reason": "user_requested"
}
}
Disconnect Reasons:
user_requested - User explicitly disconnected
token_expired - OAuth token could not be refreshed
revoked - User revoked access from Google
user.calendar.refresh_failed
Fired when OAuth token refresh fails. This is a critical event that indicates the user must re-authenticate via OAuth before any scheduling operations can proceed for this user.
Action Required: Your agent must prompt the user to re-authorize their calendar. We provide a ready-to-use OAuth URL and suggested messaging to make this easy.
When does this happen?
Google OAuth refresh tokens can become invalid for several reasons:
| Reason | Likelihood | Description |
|---|
| User revoked access | Common | User went to Google Account settings and removed your app’s access |
| 6+ months of inactivity | Common | Google expires refresh tokens that haven’t been used in 6 months |
| Password changed | Occasional | User changed their Google password |
| 50-token limit | Rare | User has authorized too many apps (Google limit) |
| App in testing mode | Development only | OAuth tokens expire after 7 days if app isn’t verified |
Payload:
{
"id": "wh_ghi789",
"event_type": "user.calendar.refresh_failed",
"timestamp": "2025-11-21T20:00:00Z",
"platform_id": "plat_xyz789",
"api_version": "v1",
"data": {
"user_email": "alice@example.com",
"google_id": "105942374304",
"failed_at": "2025-11-21T20:00:00Z",
"reason": "user_revoked_or_expired",
"action": "reauthorization_required",
"possible_causes": [
"User revoked calendar access in Google Account settings",
"Refresh token expired due to 6+ months of inactivity",
"User changed their Google password",
"Too many authorization attempts (Google 50-token limit)"
],
"oauth_url": "https://api.syncline.run/v1/auth/google/authorize?platform_key=pk_live_abc123&user_email=alice@example.com",
"suggested_user_message": "Your calendar scheduling requires attention. Please re-authorize your calendar access by clicking this link: https://api.syncline.run/v1/auth/google/authorize?platform_key=pk_live_abc123&user_email=alice@example.com"
}
}
Handling this webhook in your agent:
The oauth_url is pre-built with your platform’s publishable key and the user’s email. You can send this URL directly to your user:
case 'user.calendar.refresh_failed':
// The suggested_user_message is ready to send to your user
await sendMessageToUser(
data.user_email,
data.suggested_user_message
);
// Or customize the message but use the provided oauth_url
await sendMessageToUser(
data.user_email,
`Hi! We noticed your calendar access needs to be refreshed. ` +
`Please click here to re-authorize: ${data.oauth_url}`
);
break;
Proactive Token Refresh: Syncline automatically refreshes tokens in the background to prevent expiration. This webhook only fires when automatic refresh fails (typically because the user actively revoked access).
Meeting Events
meeting.scheduling.completed
Fired when an async scheduling job (from /v1/schedule/auto) completes successfully. Use the job_id to correlate with your original API request.
{
"id": "wh_sched123",
"event_type": "meeting.scheduling.completed",
"timestamp": "2025-11-21T16:00:05Z",
"platform_id": "plat_xyz789",
"api_version": "v1",
"data": {
"job_id": "sched_7d8f9e3a2b1c",
"success": true,
"meeting_id": "meet_456def",
"google_event_id": "event_789",
"meet_link": "https://meet.google.com/abc-defg-hij",
"chosen_slot": {
"start_time": "2025-11-25T14:00:00Z",
"end_time": "2025-11-25T14:30:00Z",
"score": 0.92,
"match_quality": "excellent"
},
"attendees": ["alice@example.com", "bob@example.com"],
"agent_reasoning": "Selected optimal slot (score: 0.92, quality: excellent) considering user preferences and context",
"processing_time_ms": 4500
}
}
meeting.scheduling.failed
Fired when an async scheduling job fails (no availability, token expired, etc.).
{
"id": "wh_sched456",
"event_type": "meeting.scheduling.failed",
"timestamp": "2025-11-21T16:00:05Z",
"platform_id": "plat_xyz789",
"api_version": "v1",
"data": {
"job_id": "sched_7d8f9e3a2b1c",
"success": false,
"error_code": "NO_AVAILABILITY",
"error_message": "No mutual availability found in the next 14 days",
"attendees": ["alice@example.com", "bob@example.com"],
"suggestions": [
"Try expanding the date range",
"Check if all attendees have connected calendars",
"Consider reducing meeting duration"
],
"processing_time_ms": 3200
}
}
Error Codes:
NO_AVAILABILITY - No overlapping free time found
CALENDAR_NOT_FOUND - Attendee hasn’t connected calendar
TOKEN_EXPIRED - Calendar token needs reauthorization
TIMEZONE_ERROR - Could not determine timezone
GOOGLE_API_ERROR - Google Calendar API error
USAGE_LIMIT_EXCEEDED - Monthly meeting limit reached
INTERNAL_ERROR - Unexpected server error
meeting.scheduled
Fired when a meeting is successfully created.
{
"id": "wh_jkl012",
"event_type": "meeting.scheduled",
"timestamp": "2025-11-21T16:00:00Z",
"platform_id": "plat_xyz789",
"api_version": "v1",
"data": {
"meeting_id": "meet_abc123",
"attendees": ["alice@example.com", "bob@example.com"],
"start_time": "2025-11-25T14:00:00-08:00",
"end_time": "2025-11-25T14:30:00-08:00",
"duration": 30,
"location": "https://meet.google.com/xyz-defg-hij",
"meeting_url": "https://meet.google.com/xyz-defg-hij",
"scheduled_at": "2025-11-21T16:00:00Z",
"scheduled_by": "plat_xyz789"
}
}
meeting.updated
Fired when a meeting is rescheduled or modified via PUT /v1/meetings/{id}.
{
"id": "wh_mno345",
"event_type": "meeting.updated",
"timestamp": "2025-11-22T11:00:00Z",
"platform_id": "plat_xyz789",
"api_version": "v1",
"data": {
"meeting_id": "meet_abc123",
"attendees": ["alice@example.com", "bob@example.com"],
"updated_at": "2025-11-22T11:00:00Z",
"changes": {
"updated_by": "plat_xyz789",
"reason": "meeting_rescheduled",
"old_start_time": "2025-11-25T14:00:00-08:00",
"new_start_time": "2025-11-26T15:00:00-08:00",
"old_end_time": "2025-11-25T14:30:00-08:00",
"new_end_time": "2025-11-26T15:30:00-08:00"
}
}
}
meeting.cancelled
Fired when a meeting is cancelled via DELETE /v1/meetings/{id}.
{
"id": "wh_pqr678",
"event_type": "meeting.cancelled",
"timestamp": "2025-11-22T10:30:00Z",
"platform_id": "plat_xyz789",
"api_version": "v1",
"data": {
"meeting_id": "meet_abc123",
"attendees": ["alice@example.com", "bob@example.com"],
"start_time": "2025-11-25T14:00:00-08:00",
"cancelled_at": "2025-11-22T10:30:00Z",
"cancelled_by": "plat_xyz789",
"reason": "cancelled_by_platform"
}
}
Fired when your platform reaches 80% of monthly usage quota.
{
"id": "wh_stu901",
"event_type": "platform.usage_warning",
"timestamp": "2025-11-21T18:00:00Z",
"platform_id": "plat_xyz789",
"api_version": "v1",
"data": {
"current_usage": 800,
"limit": 1000,
"percentage": 80,
"warned_at": "2025-11-21T18:00:00Z",
"plan": "pro",
"message": "Your platform has used 80% of your monthly limit"
}
}
Fired when your platform hits 100% of monthly usage quota.
{
"id": "wh_vwx234",
"event_type": "platform.usage_limit_reached",
"timestamp": "2025-11-22T12:00:00Z",
"platform_id": "plat_xyz789",
"api_version": "v1",
"data": {
"current_usage": 1000,
"limit": 1000,
"reached_at": "2025-11-22T12:00:00Z",
"plan": "pro",
"message": "Your platform has reached its monthly usage limit. Please upgrade your plan to continue scheduling meetings."
}
}
Security
Webhook Signatures
Every webhook includes these headers for verification:
X-Syncline-Signature: 5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
X-Syncline-Timestamp: 1733267890
X-Syncline-Event: user.calendar.connected
X-Syncline-Delivery: del_abc123
The signature is computed as HMAC-SHA256(timestamp:payload, secret) where:
timestamp is the Unix timestamp from X-Syncline-Timestamp header
payload is the raw request body
secret is your webhook secret
IMPORTANT: The webhook secret is shown ONLY ONCE in your developer dashboard when you configure webhooks. Store it securely - never commit it to your code repository.
Getting Your Webhook Secret
- Log in to your developer dashboard
- Go to Settings → Webhooks
- When you create/update your webhook URL, a secret is generated
- Copy and store this secret immediately - it won’t be shown again
- Store it as an environment variable (e.g.,
SYNCLINE_WEBHOOK_SECRET)
Verifying Webhooks - Node.js
const http = require('http');
const crypto = require('crypto');
const PORT = process.env.PORT || 4000;
const SECRET = process.env.SYNCLINE_WEBHOOK_SECRET;
function verifyWebhookSignature(payload, signature, timestamp, secret) {
if (!secret || !signature || !timestamp) {
return false;
}
// Build signing string: timestamp:payload
const signingString = timestamp + ':' + payload.toString('utf8');
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signingString)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expectedSignature, 'hex')
);
}
const server = http.createServer((req, res) => {
if (req.method !== 'POST') {
res.writeHead(405);
return res.end('Method Not Allowed');
}
const signature = req.headers['x-syncline-signature'];
const timestamp = req.headers['x-syncline-timestamp'];
const chunks = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => {
const rawBody = Buffer.concat(chunks);
if (!verifyWebhookSignature(rawBody, signature, timestamp, SECRET)) {
res.writeHead(401);
return res.end('Invalid signature');
}
const event = JSON.parse(rawBody.toString('utf8'));
console.log('Webhook received:', event.event_type);
// Handle the webhook event...
res.writeHead(200);
res.end('OK');
});
});
server.listen(PORT, () => console.log(`Listening on port ${PORT}`));
Verifying Webhooks - Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
)
func verifySignature(payload []byte, signature, timestamp, secret string) bool {
if secret == "" || signature == "" || timestamp == "" {
return false
}
// Build signing string: timestamp:payload
signingString := fmt.Sprintf("%s:%s", timestamp, string(payload))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signingString))
expectedSignature := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expectedSignature))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
signature := r.Header.Get("X-Syncline-Signature")
timestamp := r.Header.Get("X-Syncline-Timestamp")
secret := os.Getenv("SYNCLINE_WEBHOOK_SECRET")
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
if !verifySignature(body, signature, timestamp, secret) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
var event map[string]interface{}
json.Unmarshal(body, &event)
log.Printf("Webhook received: %v", event["event_type"])
// Handle the webhook event...
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
func main() {
http.HandleFunc("/webhooks/syncline", webhookHandler)
log.Println("Listening on :4000")
log.Fatal(http.ListenAndServe(":4000", nil))
}
Verifying Webhooks - Python
import hmac
import hashlib
import os
from flask import Flask, request, abort
app = Flask(__name__)
def verify_signature(payload: bytes, signature: str, timestamp: str, secret: str) -> bool:
if not secret or not signature or not timestamp:
return False
# Build signing string: timestamp:payload
signing_string = f"{timestamp}:{payload.decode('utf-8')}"
expected = hmac.new(
secret.encode('utf-8'),
signing_string.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
@app.route('/webhooks/syncline', methods=['POST'])
def webhook_handler():
signature = request.headers.get('X-Syncline-Signature', '')
timestamp = request.headers.get('X-Syncline-Timestamp', '')
secret = os.environ.get('SYNCLINE_WEBHOOK_SECRET', '')
if not verify_signature(request.data, signature, timestamp, secret):
abort(401, 'Invalid signature')
event = request.json
print(f"Webhook received: {event.get('event_type')}")
# Handle the webhook event...
return 'OK', 200
if __name__ == '__main__':
app.run(port=4000)
Security Best Practices
Never commit your webhook secret to version control! Always use environment variables or a secrets manager.
1. Use Environment Variables
# .env (add to .gitignore!)
SYNCLINE_WEBHOOK_SECRET=whsec_abc123...xyz789
# Load in your app
require('dotenv').config();
const secret = process.env.SYNCLINE_WEBHOOK_SECRET;
2. Validate Before Processing
// Good: Verify signature before any processing
if (!verifyWebhook(payload, signature, secret)) {
return res.status(401).send('Invalid');
}
processWebhook(payload);
// Bad: Processing before verification
processWebhook(payload); // ❌ Unsafe!
if (!verifyWebhook(payload, signature, secret)) {
return res.status(401).send('Invalid');
}
3. Use HTTPS Only
Configure your webhook URL to use HTTPS, never HTTP:
✓ https://yourapp.com/webhooks/syncline
✗ http://yourapp.com/webhooks/syncline (rejected)
4. Rate Limiting
Implement rate limiting to prevent abuse:
const rateLimit = require('express-rate-limit');
const webhookLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: 100, // Max 100 webhooks per minute
message: 'Too many webhook requests'
});
app.post('/webhooks/syncline', webhookLimiter, webhookHandler);
5. Secret Rotation
Rotate your webhook secret periodically:
- Go to dashboard → Settings → Webhooks
- Click “Regenerate Secret”
- Update your environment variable with the new secret
- Old secret remains valid for 24 hours (zero-downtime rotation)
Why This Is Safe
The verification code shown above is meant to be public. This is the same pattern used by:
- Stripe webhooks
- GitHub webhooks
- Twilio webhooks
- Every major API platform
Security comes from:
- The secret (kept private in your environment)
- HTTPS encryption (webhook payloads are encrypted in transit)
- Signature verification (proves webhook came from Syncline)
What attackers CAN’T do:
- Generate valid signatures without your secret
- Intercept webhooks (HTTPS encryption)
- Replay old webhooks (timestamp validation)
Webhook Management
Test Webhook
Send a test event to verify your webhook is working:
POST https://api.syncline.run/v1/platform/webhook/test
Response:
{
"success": true,
"message": "Test webhook sent to https://your-platform.com/webhooks/syncline",
"note": "Check your webhook endpoint for the test payload. Delivery may take a few seconds."
}
View Webhook Logs
See recent webhook deliveries and their status:
GET https://api.syncline.run/v1/platform/webhook/logs
Response:
{
"deliveries": [
{
"id": "wh_del_abc123",
"event": "meeting.scheduled",
"timestamp": "2025-01-20T10:35:00Z",
"status": "success",
"response_code": 200,
"attempts": 1,
"duration_ms": 145
},
{
"id": "wh_del_def456",
"event": "user.calendar_connected",
"timestamp": "2025-01-20T10:30:00Z",
"status": "failed",
"response_code": 500,
"attempts": 3,
"duration_ms": 5000,
"error": "Connection timeout"
}
],
"stats": {
"total_sent": 1247,
"success_rate": 0.987,
"avg_response_time_ms": 156,
"last_30_days": {
"success": 1231,
"failed": 16
}
}
}
Retry Policy
If your webhook endpoint fails:
- Immediate retry after 1 second
- Second retry after 10 seconds
- Third retry after 60 seconds
After 3 failed attempts, the webhook delivery is marked as failed. You can review failed deliveries in the webhook logs.
Best Practices
Respond Quickly
Return a 200 OK response as soon as you receive the webhook. Don’t wait for processing to complete:
app.post('/webhooks/syncline', async (req, res) => {
// Return 200 immediately
res.status(200).send('OK');
// Process asynchronously
processWebhookAsync(req.body);
});
Handle Duplicates
Due to retries, you may receive the same webhook multiple times. Use the timestamp and event data to deduplicate:
const processedEvents = new Set();
function processWebhook(event) {
const eventId = `${event.event}_${event.timestamp}_${event.data.meeting_id}`;
if (processedEvents.has(eventId)) {
return; // Already processed
}
processedEvents.add(eventId);
// Process event...
}
Monitor Webhook Health
Regularly check webhook logs to ensure deliveries are succeeding. Set up alerts for high failure rates.
Example Integration
// Express.js webhook handler with signature verification
const express = require('express');
const crypto = require('crypto');
const app = express();
// IMPORTANT: Use express.raw() to get the raw body for signature verification
app.post('/webhooks/syncline', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.headers['x-syncline-signature'];
const timestamp = req.headers['x-syncline-timestamp'];
const secret = process.env.SYNCLINE_WEBHOOK_SECRET;
// Verify signature before processing
if (!verifySignature(req.body, signature, timestamp, secret)) {
return res.status(401).send('Invalid signature');
}
// Return 200 immediately
res.status(200).send('OK');
// Parse and process event asynchronously
const event = JSON.parse(req.body.toString('utf8'));
switch (event.event_type) {
case 'user.calendar.connected':
console.log(`User ${event.data.user_email} connected calendar`);
// Update your database, send welcome email, etc.
break;
case 'user.calendar.refresh_failed':
console.log(`Calendar refresh failed for ${event.data.user_email}`);
// CRITICAL: Send the user the oauth_url to re-authorize
// The suggested_user_message is ready to use
await notifyUser(event.data.user_email, event.data.suggested_user_message);
break;
case 'meeting.scheduled':
console.log(`Meeting ${event.data.meeting_id} scheduled`);
// Notify users, update CRM, log analytics, etc.
break;
case 'meeting.updated':
console.log(`Meeting ${event.data.meeting_id} updated`);
// Notify users of changes
break;
case 'meeting.cancelled':
console.log(`Meeting ${event.data.meeting_id} cancelled`);
// Update status, notify stakeholders
break;
}
});
function verifySignature(payload, signature, timestamp, secret) {
if (!secret || !signature || !timestamp) {
return false;
}
// Build signing string: timestamp:payload
const signingString = timestamp + ':' + payload.toString('utf8');
const expected = crypto
.createHmac('sha256', secret)
.update(signingString)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
);
}
app.listen(3000);