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 β
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:
{
"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:
- Receives POST requests
- Verifies the HMAC signature
- Returns 200 OK within 10 seconds
- Processes events in background (for heavy operations)
Step 3: Verify Signatures β
Always verify that webhooks are from Whiteboard Service using HMAC-SHA256:
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
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]);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:
| Pattern | Matches |
|---|---|
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) |
*.created | All creation events across entity types |
* | All events in the system |
Board Events β
| Event | Description | Triggers When |
|---|---|---|
board.created | Board created via API | New board is created |
board.ended | Board session ended | Board explicitly ended |
board.archived | Board moved to archive | Archive action triggered |
board.unarchived | Board restored from archive | Unarchive action triggered |
board.deleted | Board permanently deleted | Permanent deletion triggered |
board.expired | Board expired by TTL | Time-to-live expires |
Example: board.created
{
"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 β
| Event | Description |
|---|---|
session.started | First user joins the board |
session.ended | Last user leaves the board |
participant.joined | New participant connects |
participant.left | Participant disconnects |
Example: participant.joined
{
"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 β
| Event | Description |
|---|---|
object.created | New object added to board |
object.updated | Object data modified |
object.deleted | Object removed from board |
object.action | Custom action performed on object |
Example: object.created
{
"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
{
"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
{
"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:
{
"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:
- Extract raw request body (before JSON parsing)
- Create HMAC-SHA256 using the signature secret
- Compare with
X-Webhook-Signatureheader using constant-time comparison - 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:
| Attempt | Delay | Jitter |
|---|---|---|
| 1 | Immediate | β |
| 2 | 1 second | +0-1000ms |
| 3 | 5 seconds | +0-1000ms |
| 4 | 25 seconds | +0-1000ms |
Auto-Disable Behavior:
- Subscriptions are automatically disabled after 10 consecutive delivery failures
- Re-enable via API:
PATCH /api/v1/webhooks/subscriptions/:idwith{"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 β
- Go to https://webhook.site
- Copy your unique URL
- Create a subscription:
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": ["*"]
}'- Trigger an event (create a board):
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"}'- Check webhook.site - you'll see the webhook delivery!
View Delivery Logs β
Check delivery status and debugging information:
curl "http://localhost:4000/api/v1/webhooks/deliveries?limit=10" \
-H "X-API-Key: wb_your_api_key" | jqCommon Integration Patterns β
Attendance Tracking (BigBen CRM) β
Track student attendance using external context:
<?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:
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:
- Check subscription is active:
curl http://localhost:4000/api/v1/webhooks/subscriptions \
-H "X-API-Key: your-key" | jq- Check delivery logs:
curl "http://localhost:4000/api/v1/webhooks/deliveries?limit=10" \
-H "X-API-Key: your-key" | jqVerify endpoint is accessible and returns 200 OK
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:
// β 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:
- Check delivery logs to identify issue
- Fix your webhook endpoint
- Re-enable subscription:
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
$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/subscriptionsRequest:
{
"url": "https://example.com/webhooks",
"events": ["board.*", "session.*"],
"description": "Optional description"
}Response:
{
"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/subscriptionsGet Webhook Subscription β
GET /api/v1/webhooks/subscriptions/:idUpdate Webhook Subscription β
PATCH /api/v1/webhooks/subscriptions/:idRequest:
{
"url": "https://new-url.com/webhooks",
"events": ["board.*"],
"is_active": false
}Delete Webhook Subscription β
DELETE /api/v1/webhooks/subscriptions/:idGet Webhook Deliveries β
GET /api/v1/webhooks/deliveriesQuery Parameters:
subscription_id- Filter by subscriptionstatus- Filter by status (success, failed, pending)limit- Number of results (default: 50, max: 200)
Get Subscription Delivery Statistics β
GET /api/v1/webhooks/subscriptions/:id/statsResponse:
{
"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:
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 β
| Type | Description | External IDs |
|---|---|---|
verified-student | Verified from external system | β
external_user_id + external_source |
authenticated | Regular authenticated user | β No external IDs |
anonymous | Public board visitor | β No external IDs |