> ## Documentation Index
> Fetch the complete documentation index at: https://docs.syncline.run/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive real-time notifications about scheduling events

## 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:

```bash theme={null}
PUT https://api.syncline.run/v1/platform/webhook
```

### Request Body

```json theme={null}
{
  "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:

```json theme={null}
{
  "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**.

```json theme={null}
{
  "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.

```json theme={null}
{
  "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.

<Warning>
  **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.
</Warning>

**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**:

```json theme={null}
{
  "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:

```javascript theme={null}
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;
```

<Info>
  **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).
</Info>

## 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.

```json theme={null}
{
  "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.).

```json theme={null}
{
  "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.

```json theme={null}
{
  "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}`.

```json theme={null}
{
  "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}`.

```json theme={null}
{
  "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.

```json theme={null}
{
  "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.

```json theme={null}
{
  "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](https://syncline.run/developer/login)
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

```javascript theme={null}
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

```go theme={null}
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

```python theme={null}
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

<Warning>
  **Never commit your webhook secret to version control!** Always use environment variables or a secrets manager.
</Warning>

#### 1. Use Environment Variables

```bash theme={null}
# .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

```javascript theme={null}
// 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:

```javascript theme={null}
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:

```bash theme={null}
POST https://api.syncline.run/v1/platform/webhook/test
```

Response:

```json theme={null}
{
  "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:

```bash theme={null}
GET https://api.syncline.run/v1/platform/webhook/logs
```

Response:

```json theme={null}
{
  "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:

```javascript theme={null}
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:

```javascript theme={null}
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

```javascript theme={null}
// 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);
```

## Related Endpoints

<CardGroup cols={2}>
  <Card title="Update Webhook Settings" icon="gear" href="/api-reference/platform-settings">
    Configure webhook URL and events
  </Card>

  <Card title="View Webhook Logs" icon="list" href="/api-reference/webhook-logs">
    Monitor webhook deliveries
  </Card>
</CardGroup>
