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:
- Incoming: Chalk receives
recording.readyfrom Cloudflare when a recording finishes - Outgoing: After processing (transcription, AI analysis), Chalk sends
meeting.recording_readyto 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/configAuthorization: Bearer ck_live_xxxContent-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
| Field | Type | Required | Description |
|---|---|---|---|
enabled | boolean | Yes | Enable/disable webhook delivery |
url | string | Yes | Your webhook endpoint (HTTPS required in production) |
secret | string | Yes | Secret for signature verification (prefix with whsec_) |
include_recording | boolean | No | Include recording download URL in payload |
include_transcript | boolean | No | Include full transcript text and segments |
include_summary | boolean | No | Include AI-generated meeting summary |
include_action_items | boolean | No | Include extracted action items |
transcription.provider | string | No | Transcription provider: groq or whisper |
transcription.api_key | string | No | BYOK API key for transcription provider |
ai.provider | string | No | AI provider: openrouter |
ai.api_key | string | No | BYOK API key for AI provider |
ai.model | string | No | AI 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-readyHeaders
| Header | Description |
|---|---|
X-Cloudflare-Signature | HMAC-SHA256 signature of the request body (hex-encoded) |
Content-Type | application/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:
- Verifies the signature using
CLOUDFLARE_WEBHOOK_SECRET - Downloads the recording from the temporary URL
- Uploads to permanent storage (R2)
- Updates recording status to
completed - 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
| Header | Description |
|---|---|
X-Chalk-Signature | HMAC-SHA256 signature (format: sha256=<hex>) |
X-Chalk-Timestamp | Unix timestamp when the webhook was generated |
X-Chalk-Event | Event type: meeting.recording_ready |
Content-Type | application/json |
User-Agent | Chalk-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:
| Field | Included When | Description |
|---|---|---|
event | Always | Event type: meeting.recording_ready |
timestamp | Always | ISO 8601 timestamp of webhook generation |
meeting | Always | Meeting metadata |
recording | include_recording: true | Recording details with download URL |
transcript | include_transcript: true | Full transcript with segments |
summary | include_summary: true | AI-generated meeting summary |
action_items | include_action_items: true | Extracted action items array |
errors | On failures | Graceful 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:
secretis your configured webhook secrettimestampis the value fromX-Chalk-Timestampheaderpayloadis 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 hmacimport hashlibimport 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) );}
// Middlewareapp.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:
- Return 2xx status within 30 seconds for successful processing
- Return non-2xx to trigger retry (except 4xx which is permanent failure)
| Response | Behavior |
|---|---|
2xx | Success, delivery marked complete |
4xx (except 429) | Permanent failure, no retry |
429 | Rate limited, retry with backoff |
5xx | Temporary failure, retry |
| Timeout (>30s) | Retry |
| Network error | Retry |
Retry Policy
Failed deliveries retry with exponential backoff:
| Attempt | Delay After Failure | Cumulative Time |
|---|---|---|
| 1 | Immediate | 0 |
| 2 | 1 minute | 1m |
| 3 | 5 minutes | 6m |
| 4 | 15 minutes | 21m |
| 5 | 1 hour | 1h 21m |
| 6 (final) | 4 hours | 5h 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 minutesconst 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 keyconst 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
| Event | Description | Trigger |
|---|---|---|
meeting.recording_ready | Recording processed with optional transcript/summary | Post-meeting processing completes |
Future Events (Planned)
| Event | Description |
|---|---|
meeting.started | Meeting begins |
meeting.ended | Meeting ends (without recording) |
participant.joined | Participant joins meeting |
participant.left | Participant leaves meeting |
transcription.completed | Live transcription segment ready |
Troubleshooting
Webhook Not Received
- Verify
post_meeting_webhook.enabledistrue - Check URL is accessible from the internet
- Ensure HTTPS is configured (required in production)
- Check your server logs for incoming requests
Signature Verification Failing
- Use the raw request body, not parsed JSON
- Ensure you’re using the correct secret from tenant config
- Verify timestamp is being read as a string, not parsed
- Check for encoding issues (UTF-8)
Missing Data in Payload
- Verify the corresponding
include_*flag istrue - Check
errorsarray for processing failures - Ensure recording completed successfully
Retries Not Working
- Return non-2xx status to trigger retry
- 4xx responses (except 429) are permanent failures
- Check webhook delivery history via API