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"
}
}
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 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
- 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
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:
- 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
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);