Skip to content

Webhooks ​

Real-time event notifications for integrating Whiteboard Service with external systems. Webhooks allow your application to receive HTTP POST notifications when events occur on boards, sessions, participants, and objects.

What are Webhooks? ​

Webhooks are HTTP callbacks triggered by events in the Whiteboard Service. Instead of continuously polling the API, your application receives instant notifications when:

  • A board is created, ended, or deleted
  • Users join or leave a session
  • Objects are created, updated, or deleted
  • Custom actions are performed on objects

Key Features:

  • Real-time event delivery
  • HMAC-SHA256 signature verification for security
  • Automatic retry with exponential backoff (up to 3 attempts)
  • Wildcard subscription patterns for flexible filtering
  • Complete audit trail with delivery logs
  • Idempotency support via unique delivery_id

How to Set Up Webhooks ​

Step 1: Create a Webhook Subscription ​

bash
curl -X POST http://localhost:4000/api/v1/webhooks/subscriptions \
  -H "Content-Type: application/json" \
  -H "X-API-Key: wb_your_api_key" \
  -d '{
    "url": "https://example.com/webhooks/whiteboard",
    "events": ["board.*", "session.*", "object.action"],
    "description": "My webhook subscription"
  }'

Response:

json
{
  "id": "sub_uuid",
  "url": "https://example.com/webhooks/whiteboard",
  "events": ["board.*", "session.*", "object.action"],
  "secret": "64-char-hex-secret",
  "secret_hint": "a1b2c3d4...",
  "is_active": true,
  "created_at": "2025-11-17T10:00:00.000Z"
}

Important: Save the secret immediately - it's only shown once!

Step 2: Implement Your Webhook Receiver ​

Create an HTTPS endpoint that:

  1. Receives POST requests
  2. Verifies the HMAC signature
  3. Returns 200 OK within 10 seconds
  4. Processes events in background (for heavy operations)

Step 3: Verify Signatures ​

Always verify that webhooks are from Whiteboard Service using HMAC-SHA256:

javascript
const crypto = require('crypto');
const express = require('express');

app.post('/webhooks/whiteboard', express.json(), (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const secret = process.env.WHITEBOARD_WEBHOOK_SECRET;

  // Verify HMAC signature
  const hmac = crypto.createHmac('sha256', secret);
  hmac.update(JSON.stringify(req.body));
  const expectedSignature = hmac.digest('hex');

  if (!crypto.timingSafeEqual(
    Buffer.from(signature, 'hex'),
    Buffer.from(expectedSignature, 'hex')
  )) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Process webhook
  const { event, data } = req.body;
  console.log(`βœ… Valid webhook: ${event}`);

  res.json({ success: true });
});
php
<?php
function verifyWebhookSignature($payload, $signature, $secret) {
    $expectedSignature = hash_hmac('sha256', $payload, $secret);
    return hash_equals($signature, $expectedSignature);
}

// Usage
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$secret = getenv('WHITEBOARD_WEBHOOK_SECRET');

if (!verifyWebhookSignature($payload, $signature, $secret)) {
    http_response_code(401);
    exit(json_encode(['error' => 'Invalid signature']));
}

$data = json_decode($payload, true);
$event = $data['event'];

error_log("βœ… Valid webhook: $event");
http_response_code(200);
echo json_encode(['success' => true]);
python
import hmac
import hashlib
from flask import Flask, request, jsonify

def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
    expected_signature = hmac.new(
        secret.encode('utf-8'),
        payload,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, expected_signature)

app = Flask(__name__)

@app.route('/webhooks/whiteboard', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Webhook-Signature')
    secret = os.environ.get('WHITEBOARD_WEBHOOK_SECRET')

    if not verify_webhook_signature(request.data, signature, secret):
        return {'error': 'Invalid signature'}, 401

    data = request.json
    event = data['event']

    print(f"βœ… Valid webhook: {event}")
    return {'success': True}

Event Types ​

Webhooks support flexible event subscriptions using wildcard patterns or specific event names.

Wildcard Subscriptions ​

Subscribe to event patterns to reduce noise and simplify filtering:

PatternMatches
board.*All board events (created, ended, deleted, expired)
session.*Session events (started, ended)
participant.*Participant events (joined, left)
object.*Object events (created, updated, deleted, action)
*.createdAll creation events across entity types
*All events in the system

Board Events ​

EventDescriptionTriggers When
board.createdBoard created via APINew board is created
board.endedBoard session endedBoard explicitly ended
board.archivedBoard moved to archiveArchive action triggered
board.unarchivedBoard restored from archiveUnarchive action triggered
board.deletedBoard permanently deletedPermanent deletion triggered
board.expiredBoard expired by TTLTime-to-live expires

Example: board.created

json
{
  "delivery_id": "550e8400-e29b-41d4-a716-446655440000",
  "event": "board.created",
  "timestamp": "2025-11-17T10:00:00.000Z",
  "data": {
    "board_id": "a1b2c3d4-uuid",
    "organization_id": "org-uuid",
    "title": "English Lesson",
    "external_id": "lesson_12345",
    "host_link": "https://board.example.com/board/a1b2c3d4?token=...",
    "guest_link": "https://board.example.com/board/a1b2c3d4?token=...",
    "created_at": "2025-11-17T10:00:00.000Z"
  }
}

Session & Participant Events ​

EventDescription
session.startedFirst user joins the board
session.endedLast user leaves the board
participant.joinedNew participant connects
participant.leftParticipant disconnects

Example: participant.joined

json
{
  "delivery_id": "660e9500-f39c-52e5-b827-557766551111",
  "event": "participant.joined",
  "timestamp": "2025-11-17T10:05:00.000Z",
  "data": {
    "board_id": "a1b2c3d4-uuid",
    "organization_id": "org-uuid",
    "board": {
      "title": "English Lesson",
      "external_id": "lesson_12345",
      "external_data": {
        "lesson_id": "lesson_12345",
        "course_id": "course_A",
        "teacher_id": "teacher_789"
      },
      "created_at": "2025-11-17T09:00:00.000Z"
    },
    "user": {
      "user_id": "user_123",
      "user_name": "Ivan Petrov",
      "user_role": "guest",
      "user_type": "verified-student",
      "external_user_id": "student_42",
      "external_source": "bigben-crm"
    },
    "participant_count": 8,
    "joined_at": "2025-11-17T10:05:00.000Z"
  }
}

Object Events ​

EventDescription
object.createdNew object added to board
object.updatedObject data modified
object.deletedObject removed from board
object.actionCustom action performed on object

Example: object.created

json
{
  "delivery_id": "770f0611-g40d-63f6-c938-668877662222",
  "event": "object.created",
  "timestamp": "2025-11-17T10:10:00.000Z",
  "data": {
    "board_id": "a1b2c3d4-uuid",
    "organization_id": "org-uuid",
    "object_id": "obj_1",
    "object_type": "vocabulary-card",
    "object_data": {
      "word": "apple",
      "translation": "яблоко",
      "image_url": "https://cdn.example.com/apple.jpg"
    },
    "version": 5,
    "created_by": "user_123"
  }
}

Example: object.updated

json
{
  "delivery_id": "880g1722-h51e-74g7-d049-779988773333",
  "event": "object.updated",
  "timestamp": "2025-11-17T10:12:00.000Z",
  "data": {
    "board_id": "a1b2c3d4-uuid",
    "organization_id": "org-uuid",
    "object_id": "obj_1",
    "object_type": "vocabulary-card",
    "changes": {
      "before": {
        "word": "aple",
        "translation": "яблоко"
      },
      "after": {
        "word": "apple",
        "translation": "яблоко"
      }
    },
    "version": 6,
    "updated_by": "user_123"
  }
}

Example: object.action

json
{
  "delivery_id": "990h2833-i62f-85h8-e150-880099884444",
  "event": "object.action",
  "timestamp": "2025-11-17T10:15:00.000Z",
  "data": {
    "board_id": "a1b2c3d4-uuid",
    "organization_id": "org-uuid",
    "object_id": "obj_1",
    "object_type": "vocabulary-card",
    "object_data": {
      "word": "apple",
      "translation": "яблоко"
    },
    "action_id": "flip",
    "action_label": "Flip Card",
    "action_metadata": {},
    "user_id": "user_123",
    "user_name": "Ivan Petrov",
    "user_role": "guest",
    "action_count": 3,
    "timestamp": "2025-11-17T10:15:00.000Z"
  }
}

Payload Format ​

All webhook payloads follow this structure:

json
{
  "delivery_id": "550e8400-e29b-41d4-a716-446655440000",
  "event": "participant.joined",
  "timestamp": "2025-11-17T10:00:00.000Z",
  "data": {
    "board_id": "uuid",
    "organization_id": "org-uuid",
    "board": {
      "title": "string",
      "external_id": "string",
      "external_data": {
        "custom": "data"
      },
      "created_at": "ISO-8601"
    },
    "user": {
      "user_id": "uuid",
      "user_name": "string",
      "user_role": "host|guest",
      "user_type": "verified-student|authenticated|anonymous",
      "external_user_id": "string|null",
      "external_source": "string|null"
    }
  }
}

Key Fields:

  • delivery_id - Unique identifier, same across retries (use for idempotency)
  • event - Event type matching your subscription pattern
  • timestamp - ISO 8601 timestamp (UTC)
  • data - Event-specific payload with complete context
  • board - Complete board information including external_data
  • user - Complete user information including external context

Security ​

HMAC Signature Verification ​

Every webhook includes an X-Webhook-Signature header containing an HMAC-SHA256 signature of the payload.

Headers:

POST /webhooks/whiteboard HTTP/1.1
Content-Type: application/json
X-Webhook-Signature: a1b2c3d4e5f6... (64-char hex)
X-Webhook-Event: board.created
User-Agent: Whiteboard-Service/1.0

{payload}

Verification Steps:

  1. Extract raw request body (before JSON parsing)
  2. Create HMAC-SHA256 using the signature secret
  3. Compare with X-Webhook-Signature header using constant-time comparison
  4. Reject if signature doesn't match

Important

Always verify signatures before processing webhooks. Never trust unverified payloads.

Best Practices ​

  • βœ… Always verify HMAC signatures
  • βœ… Use HTTPS for webhook URLs in production
  • βœ… Rotate secrets periodically (quarterly recommended)
  • βœ… Implement idempotency with delivery_id
  • βœ… Return 200 OK within 10 seconds
  • βœ… Process heavy operations in background queues

Retry Policy ​

The Whiteboard Service automatically retries failed deliveries up to 3 times:

AttemptDelayJitter
1Immediateβ€”
21 second+0-1000ms
35 seconds+0-1000ms
425 seconds+0-1000ms

Auto-Disable Behavior:

  • Subscriptions are automatically disabled after 10 consecutive delivery failures
  • Re-enable via API: PATCH /api/v1/webhooks/subscriptions/:id with {"is_active": true}

Idempotency:

  • Every delivery includes a unique delivery_id (UUID)
  • Same ID used across all retry attempts
  • Implement idempotency to prevent duplicate processing

Testing Webhooks ​

Quick Test with webhook.site ​

  1. Go to https://webhook.site
  2. Copy your unique URL
  3. Create a subscription:
bash
curl -X POST http://localhost:4000/api/v1/webhooks/subscriptions \
  -H "Content-Type: application/json" \
  -H "X-API-Key: wb_your_api_key" \
  -d '{
    "url": "https://webhook.site/your-id",
    "events": ["*"]
  }'
  1. Trigger an event (create a board):
bash
curl -X POST http://localhost:4000/api/v1/boards \
  -H "Content-Type: application/json" \
  -H "X-API-Key: wb_your_api_key" \
  -d '{"title": "Test Board"}'
  1. Check webhook.site - you'll see the webhook delivery!

View Delivery Logs ​

Check delivery status and debugging information:

bash
curl "http://localhost:4000/api/v1/webhooks/deliveries?limit=10" \
  -H "X-API-Key: wb_your_api_key" | jq

Common Integration Patterns ​

Attendance Tracking (BigBen CRM) ​

Track student attendance using external context:

php
<?php
$payload = json_decode(file_get_contents('php://input'), true);

if ($payload['event'] === 'participant.joined') {
    $data = $payload['data'];

    // Check if student is from your CRM
    if ($data['user']['external_source'] === 'bigben-crm') {
        $studentId = $data['user']['external_user_id'];
        $boardData = $data['board']['external_data'];
        $lessonId = $boardData['lesson_id'];
        $courseId = $boardData['course_id'];

        // Record attendance
        $db->prepare("
            INSERT INTO attendance (student_id, lesson_id, course_id, joined_at)
            VALUES (?, ?, ?, ?)
        ")->execute([$studentId, $lessonId, $courseId, $data['joined_at']]);
    }
}

Analytics Service ​

Track all events for analytics:

javascript
app.post('/webhooks/whiteboard', async (req, res) => {
  const { event, data } = req.body;

  // Track event
  await analytics.track({
    eventType: event,
    organizationId: data.organization_id,
    metadata: data
  });

  // Specific metrics
  if (event === 'session.ended') {
    await metrics.record('session_duration', data.session_duration_seconds);
  }

  res.json({ success: true });
});

Troubleshooting ​

Webhook Not Delivered ​

Debugging steps:

  1. Check subscription is active:
bash
curl http://localhost:4000/api/v1/webhooks/subscriptions \
  -H "X-API-Key: your-key" | jq
  1. Check delivery logs:
bash
curl "http://localhost:4000/api/v1/webhooks/deliveries?limit=10" \
  -H "X-API-Key: your-key" | jq
  1. Verify endpoint is accessible and returns 200 OK

  2. Check network/firewall allows outbound connections

Signature Verification Fails ​

Common causes:

  • Using parsed JSON instead of raw payload for HMAC
  • Using incorrect secret
  • Request body modified after receipt

Solution: Always use raw request body (before JSON parsing) for signature verification:

javascript
// ❌ WRONG
app.use(express.json());
const payload = req.body; // Already parsed

// βœ… CORRECT
app.use(express.raw({ type: 'application/json' }));
const payload = req.body.toString('utf-8');

Subscription Auto-Disabled ​

Cause: 10 consecutive delivery failures

Fix:

  1. Check delivery logs to identify issue
  2. Fix your webhook endpoint
  3. Re-enable subscription:
bash
curl -X PATCH http://localhost:4000/api/v1/webhooks/subscriptions/YOUR_ID \
  -H "Content-Type: application/json" \
  -H "X-API-Key: your-key" \
  -d '{"is_active": true}'

Duplicate Events ​

Cause: Webhook retries or network failures

Solution: Implement idempotency using delivery_id:

php
<?php
$payload = json_decode(file_get_contents('php://input'), true);
$deliveryId = $payload['delivery_id'];

// Check if already processed
$stmt = $db->prepare("SELECT id FROM processed_webhooks WHERE delivery_id = ?");
$stmt->execute([$deliveryId]);

if ($stmt->rowCount() > 0) {
    // Already processed - return success
    http_response_code(200);
    exit(json_encode(['success' => true]));
}

// Mark as processed
$db->prepare("INSERT INTO processed_webhooks (delivery_id, processed_at) VALUES (?, NOW())")
    ->execute([$deliveryId]);

// Process webhook...

API Reference ​

Create Webhook Subscription ​

POST /api/v1/webhooks/subscriptions

Request:

json
{
  "url": "https://example.com/webhooks",
  "events": ["board.*", "session.*"],
  "description": "Optional description"
}

Response:

json
{
  "id": "uuid",
  "url": "https://example.com/webhooks",
  "events": ["board.*", "session.*"],
  "secret": "64-char-hex-secret",
  "secret_hint": "a1b2c3d4...",
  "is_active": true,
  "created_at": "2025-11-17T10:00:00.000Z"
}

List Webhook Subscriptions ​

GET /api/v1/webhooks/subscriptions

Get Webhook Subscription ​

GET /api/v1/webhooks/subscriptions/:id

Update Webhook Subscription ​

PATCH /api/v1/webhooks/subscriptions/:id

Request:

json
{
  "url": "https://new-url.com/webhooks",
  "events": ["board.*"],
  "is_active": false
}

Delete Webhook Subscription ​

DELETE /api/v1/webhooks/subscriptions/:id

Get Webhook Deliveries ​

GET /api/v1/webhooks/deliveries

Query Parameters:

  • subscription_id - Filter by subscription
  • status - Filter by status (success, failed, pending)
  • limit - Number of results (default: 50, max: 200)

Get Subscription Delivery Statistics ​

GET /api/v1/webhooks/subscriptions/:id/stats

Response:

json
{
  "total": 142,
  "success": 140,
  "failed": 2,
  "pending": 0,
  "success_rate": 98.59,
  "avg_response_time_ms": 234
}

External Context & Flexible Data ​

Webhooks automatically include complete board and user context:

Board External Data ​

Store arbitrary context when creating boards:

bash
curl -X POST http://localhost:4000/api/v1/boards \
  -H "X-API-Key: your_key" \
  -d '{
    "title": "English Lesson",
    "external_id": "lesson_12345",
    "external_data": {
      "lesson_id": "lesson_12345",
      "course_id": "course_A",
      "teacher_id": "teacher_789",
      "classroom": "Room 5",
      "semester": "Fall 2025",
      "custom_attributes": {
        "difficulty_level": "advanced"
      }
    }
  }'

This data is automatically included in all webhook events for that board.

User Types ​

TypeDescriptionExternal IDs
verified-studentVerified from external systemβœ… external_user_id + external_source
authenticatedRegular authenticated user❌ No external IDs
anonymousPublic board visitor❌ No external IDs

Next Steps ​