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 8 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 (user may have revoked access from Google). Action Required: Platform should prompt user to reconnect their calendar.
{
  "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",
    "error": "invalid_grant",
    "message": "OAuth token refresh failed. User may have revoked access."
  }
}

Meeting Events

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 a signature in the X-Syncline-Signature header for verification:
X-Syncline-Signature: v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
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

const crypto = require('crypto');

function verifyWebhook(payload, signature, secret) {
  const expectedSignature = 'v1=' + crypto
    .createHmac('sha256', secret)
    .update(JSON.stringify(payload))
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

// In your webhook handler
app.post('/webhooks/syncline', (req, res) => {
  const signature = req.headers['x-syncline-signature'];

  // IMPORTANT: Load secret from environment variable, NEVER hardcode it
  const secret = process.env.SYNCLINE_WEBHOOK_SECRET;

  if (!secret) {
    console.error('SYNCLINE_WEBHOOK_SECRET not configured');
    return res.status(500).send('Webhook secret not configured');
  }

  if (!verifyWebhook(req.body, signature, secret)) {
    console.warn('Invalid webhook signature received');
    return res.status(401).send('Invalid signature');
  }

  // Webhook verified - safe to process
  res.status(200).send('OK');
  processWebhookAsync(req.body);
});

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
const express = require('express');
const crypto = require('crypto');

const app = express();
app.use(express.json());

app.post('/webhooks/syncline', (req, res) => {
  // Verify signature
  const signature = req.headers['x-syncline-signature'];
  const secret = process.env.SYNCLINE_WEBHOOK_SECRET;

  if (!verifySignature(req.body, signature, secret)) {
    return res.status(401).send('Invalid signature');
  }

  // Return 200 immediately
  res.status(200).send('OK');

  // Process event asynchronously
  const { event, data } = req.body;

  switch (event) {
    case 'user.calendar_connected':
      console.log(`User ${data.user_email} connected calendar`);
      // Update your database, send welcome email, etc.
      break;

    case 'meeting.scheduled':
      console.log(`Meeting ${data.meeting_id} scheduled`);
      // Notify users, update CRM, log analytics, etc.
      break;

    case 'meeting.updated':
      console.log(`Meeting ${data.meeting_id} updated`);
      // Notify users of changes
      break;

    case 'meeting.cancelled':
      console.log(`Meeting ${data.meeting_id} cancelled`);
      // Update status, notify stakeholders
      break;
  }
});

function verifySignature(payload, signature, secret) {
  const expected = 'v1=' + crypto
    .createHmac('sha256', secret)
    .update(JSON.stringify(payload))
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

app.listen(3000);