Skip to main content

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:
ReasonLikelihoodDescription
User revoked accessCommonUser went to Google Account settings and removed your app’s access
6+ months of inactivityCommonGoogle expires refresh tokens that haven’t been used in 6 months
Password changedOccasionalUser changed their Google password
50-token limitRareUser has authorized too many apps (Google limit)
App in testing modeDevelopment onlyOAuth 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"
  }
}

Platform Events

platform.usage_warning

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"
  }
}

platform.usage_limit_reached

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

  1. Log in to your developer dashboard
  2. Go to Settings → Webhooks
  3. When you create/update your webhook URL, a secret is generated
  4. Copy and store this secret immediately - it won’t be shown again
  5. 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:
  1. Go to dashboard → Settings → Webhooks
  2. Click “Regenerate Secret”
  3. Update your environment variable with the new secret
  4. 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:
  1. Immediate retry after 1 second
  2. Second retry after 10 seconds
  3. 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);