Skip to content

Webhooks

Webhooks deliver post-meeting data to your server after recording processing completes. Configure which data to include: recordings, transcripts, AI summaries, and action items.

Overview

Chalk’s webhook system operates in two phases:

  1. Incoming: Chalk receives recording.ready from Cloudflare when a recording finishes
  2. Outgoing: After processing (transcription, AI analysis), Chalk sends meeting.recording_ready to your configured endpoint
┌─────────────┐ recording.ready ┌─────────────┐
│ Cloudflare │ ─────────────────────► │ Chalk │
│ RealtimeKit│ │ API │
└─────────────┘ └──────┬──────┘
┌─────────────┐
│ Processing │
│ • Download │
│ • Transcode │
│ • Transcribe│
│ • AI Summary│
└──────┬──────┘
meeting.recording_ready
┌─────────────┐
│ Your App │
└─────────────┘

Configuration

Configure webhooks via the tenant config API:

PATCH /api/v1/tenants/:id/config
Authorization: Bearer ck_live_xxx
Content-Type: application/json
{
"post_meeting_webhook": {
"enabled": true,
"url": "https://your-app.com/webhooks/chalk",
"secret": "whsec_your_32_byte_hex_secret",
"include_recording": true,
"include_transcript": true,
"include_summary": true,
"include_action_items": true,
"transcription": {
"provider": "groq",
"api_key": "gsk_optional_byok_key"
},
"ai": {
"provider": "openrouter",
"api_key": "sk_optional_byok_key",
"model": "anthropic/claude-3-opus"
}
}
}

Configuration Fields

FieldTypeRequiredDescription
enabledbooleanYesEnable/disable webhook delivery
urlstringYesYour webhook endpoint (HTTPS required in production)
secretstringYesSecret for signature verification (prefix with whsec_)
include_recordingbooleanNoInclude recording download URL in payload
include_transcriptbooleanNoInclude full transcript text and segments
include_summarybooleanNoInclude AI-generated meeting summary
include_action_itemsbooleanNoInclude extracted action items
transcription.providerstringNoTranscription provider: groq or whisper
transcription.api_keystringNoBYOK API key for transcription provider
ai.providerstringNoAI provider: openrouter
ai.api_keystringNoBYOK API key for AI provider
ai.modelstringNoAI model (e.g., anthropic/claude-3-opus)

Incoming Webhooks (from Cloudflare)

Chalk receives recording.ready events from Cloudflare RealtimeKit when a recording completes.

Endpoint

POST /api/v1/webhooks/cloudflare/recording-ready

Headers

HeaderDescription
X-Cloudflare-SignatureHMAC-SHA256 signature of the request body (hex-encoded)
Content-Typeapplication/json

Payload

{
"type": "recording.ready",
"recording_id": "cf_rec_abc123",
"meeting_id": "room_xyz789",
"url": "https://cloudflare-temp-url.com/recording.webm",
"duration_seconds": 1800,
"size_bytes": 52428800,
"content_type": "video/webm"
}

Processing Flow

When Chalk receives this webhook:

  1. Verifies the signature using CLOUDFLARE_WEBHOOK_SECRET
  2. Downloads the recording from the temporary URL
  3. Uploads to permanent storage (R2)
  4. Updates recording status to completed
  5. Triggers post-meeting processing based on tenant config

Outgoing Webhooks (to Your App)

When post_meeting_webhook.enabled is true, Chalk sends webhook events to your configured URL after processing completes.

Headers

HeaderDescription
X-Chalk-SignatureHMAC-SHA256 signature (format: sha256=<hex>)
X-Chalk-TimestampUnix timestamp when the webhook was generated
X-Chalk-EventEvent type: meeting.recording_ready
Content-Typeapplication/json
User-AgentChalk-Webhook/1.0

Payload Structure

{
"event": "meeting.recording_ready",
"timestamp": "2026-01-15T10:30:00Z",
"meeting": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Team Standup",
"started_at": "2026-01-15T10:00:00Z",
"ended_at": "2026-01-15T10:30:00Z",
"duration_seconds": 1800,
"participant_count": 5
},
"recording": {
"id": "660e8400-e29b-41d4-a716-446655440001",
"duration_seconds": 1800,
"size_bytes": 52428800,
"download_url": "https://r2.chalk.q9labs.ai/recordings/...?token=...",
"download_api": "/api/v1/recordings/660e8400.../download",
"expires_at": "2026-01-16T10:30:00Z"
},
"transcript": {
"id": "770e8400-e29b-41d4-a716-446655440002",
"text": "Good morning everyone. Let's start with updates...",
"word_count": 2500,
"language": "en",
"provider": "groq",
"segments": [
{
"start": 0.0,
"end": 2.5,
"text": "Good morning everyone."
},
{
"start": 2.5,
"end": 5.2,
"text": "Let's start with updates."
}
]
},
"summary": "The team discussed Q1 roadmap priorities. Key decisions: approved the new onboarding flow design, moved launch date to February 15th. John committed to finalizing API documentation by Friday.",
"action_items": [
"John: Finalize API documentation by Friday",
"Sarah: Schedule design review for new onboarding flow",
"Team: Review updated timeline in project board"
],
"errors": []
}

Payload Fields

Fields are included based on your post_meeting_webhook configuration:

FieldIncluded WhenDescription
eventAlwaysEvent type: meeting.recording_ready
timestampAlwaysISO 8601 timestamp of webhook generation
meetingAlwaysMeeting metadata
recordinginclude_recording: trueRecording details with download URL
transcriptinclude_transcript: trueFull transcript with segments
summaryinclude_summary: trueAI-generated meeting summary
action_itemsinclude_action_items: trueExtracted action items array
errorsOn failuresGraceful degradation error details

Graceful Degradation

If processing partially fails (e.g., transcription succeeds but AI summary fails), the webhook is still delivered with available data. The errors array contains details:

{
"event": "meeting.recording_ready",
"meeting": { ... },
"recording": { ... },
"transcript": { ... },
"summary": null,
"action_items": [],
"errors": [
{
"field": "summary",
"code": "ai_processing_failed",
"message": "AI provider rate limited, retry later"
}
]
}

Signature Verification

Always verify webhook signatures before processing. This prevents replay attacks and ensures authenticity.

Signature Format

X-Chalk-Signature: sha256=<hex-encoded-hmac>

The signature is computed as:

HMAC-SHA256(secret, "{timestamp}.{payload}")

Where:

  • secret is your configured webhook secret
  • timestamp is the value from X-Chalk-Timestamp header
  • payload is the raw JSON request body

TypeScript

import crypto from 'crypto';
function verifyChalkWebhook(
payload: string,
signature: string,
timestamp: string,
secret: string
): boolean {
// Validate timestamp freshness (5 minute tolerance)
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (Math.abs(age) > 300) {
return false;
}
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}

Python

import hmac
import hashlib
import time
def verify_chalk_webhook(
payload: bytes,
signature: str,
timestamp: str,
secret: str
) -> bool:
# Validate timestamp freshness (5 minute tolerance)
age = abs(time.time() - int(timestamp))
if age > 300:
return False
signed_payload = f"{timestamp}.{payload.decode()}"
expected = "sha256=" + hmac.new(
secret.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)

Go

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"strconv"
"time"
)
func VerifyChalkWebhook(payload []byte, signature, timestamp, secret string) bool {
// Validate timestamp freshness (5 minute tolerance)
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
age := math.Abs(float64(time.Now().Unix() - ts))
if age > 300 {
return false
}
signedPayload := fmt.Sprintf("%s.%s", timestamp, string(payload))
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(signedPayload))
expected := "sha256=" + hex.EncodeToString(h.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expected))
}

Express.js Middleware

import express from 'express';
import crypto from 'crypto';
const WEBHOOK_SECRET = process.env.CHALK_WEBHOOK_SECRET!;
function verifyChalkWebhook(
payload: string,
signature: string,
timestamp: string
): boolean {
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (Math.abs(age) > 300) return false;
const expected = 'sha256=' + crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${timestamp}.${payload}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// Middleware
app.post(
'/webhooks/chalk',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-chalk-signature'] as string;
const timestamp = req.headers['x-chalk-timestamp'] as string;
const payload = req.body.toString();
if (!signature || !timestamp) {
return res.status(400).json({ error: 'Missing headers' });
}
if (!verifyChalkWebhook(payload, signature, timestamp)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(payload);
// Process the webhook
console.log(`Meeting ${event.meeting.id} recording ready`);
if (event.transcript) {
// Store transcript
console.log(`Transcript: ${event.transcript.word_count} words`);
}
if (event.action_items?.length) {
// Create tasks from action items
console.log(`Action items: ${event.action_items.length}`);
}
res.status(200).json({ received: true });
}
);

Response Requirements

Your webhook endpoint must:

  1. Return 2xx status within 30 seconds for successful processing
  2. Return non-2xx to trigger retry (except 4xx which is permanent failure)
ResponseBehavior
2xxSuccess, delivery marked complete
4xx (except 429)Permanent failure, no retry
429Rate limited, retry with backoff
5xxTemporary failure, retry
Timeout (>30s)Retry
Network errorRetry

Retry Policy

Failed deliveries retry with exponential backoff:

AttemptDelay After FailureCumulative Time
1Immediate0
21 minute1m
35 minutes6m
415 minutes21m
51 hour1h 21m
6 (final)4 hours5h 21m

After 5 failed attempts, the delivery is marked as permanently failed.

Security Best Practices

Always Verify Signatures

Never process a webhook without verifying the signature. This prevents:

  • Forged requests from malicious actors
  • Replay attacks using captured requests

Use Constant-Time Comparison

Always use constant-time string comparison (e.g., crypto.timingSafeEqual, hmac.compare_digest) to prevent timing attacks.

Validate Timestamp Freshness

Reject webhooks with timestamps older than 5 minutes to prevent replay attacks:

const MAX_AGE_SECONDS = 300; // 5 minutes
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (Math.abs(age) > MAX_AGE_SECONDS) {
return res.status(401).json({ error: 'Webhook expired' });
}

Store Secrets Securely

  • Use environment variables, not hardcoded strings
  • Rotate secrets periodically using the config API
  • Use secrets manager in production (AWS Secrets Manager, HashiCorp Vault)

Use HTTPS

Always configure an HTTPS endpoint. HTTP endpoints are rejected in production.

Idempotency

Webhooks may be delivered multiple times (retries, network issues). Implement idempotent handling:

// Use meeting.id + recording.id as idempotency key
const key = `${event.meeting.id}:${event.recording?.id}`;
if (await redis.exists(`webhook:processed:${key}`)) {
return res.status(200).json({ received: true, duplicate: true });
}
await redis.setex(`webhook:processed:${key}`, 86400, '1');
// Process webhook...

Events Reference

EventDescriptionTrigger
meeting.recording_readyRecording processed with optional transcript/summaryPost-meeting processing completes

Future Events (Planned)

EventDescription
meeting.startedMeeting begins
meeting.endedMeeting ends (without recording)
participant.joinedParticipant joins meeting
participant.leftParticipant leaves meeting
transcription.completedLive transcription segment ready

Troubleshooting

Webhook Not Received

  1. Verify post_meeting_webhook.enabled is true
  2. Check URL is accessible from the internet
  3. Ensure HTTPS is configured (required in production)
  4. Check your server logs for incoming requests

Signature Verification Failing

  1. Use the raw request body, not parsed JSON
  2. Ensure you’re using the correct secret from tenant config
  3. Verify timestamp is being read as a string, not parsed
  4. Check for encoding issues (UTF-8)

Missing Data in Payload

  1. Verify the corresponding include_* flag is true
  2. Check errors array for processing failures
  3. Ensure recording completed successfully

Retries Not Working

  1. Return non-2xx status to trigger retry
  2. 4xx responses (except 429) are permanent failures
  3. Check webhook delivery history via API