๐Ÿ“… let's chat! explore the endless possibilities creating industries that don't exist. click here

speak-webhooks-events

Implement Speak webhook signature validation and event handling for language learning. Use when setting up webhook endpoints, implementing signature verification, or handling Speak event notifications for lessons and progress. Trigger with phrases like "speak webhook", "speak events", "speak webhook signature", "handle speak events", "speak notifications". allowed-tools: Read, Write, Edit, Bash(curl:*) version: 1.0.0 license: MIT author: Jeremy Longshore <jeremy@intentsolutions.io>

Allowed Tools

No tools specified

Provided by Plugin

speak-pack

Claude Code skill pack for Speak AI Language Learning Platform (24 skills)

saas packs v1.0.0
View Plugin

Installation

This skill is included in the speak-pack plugin:

/plugin install speak-pack@claude-code-plugins-plus

Click to copy

Instructions

# Speak Webhooks & Events ## Overview Securely handle Speak webhooks with signature validation for language learning event notifications. ## Prerequisites - Speak webhook secret configured - HTTPS endpoint accessible from internet - Understanding of cryptographic signatures - Redis or database for idempotency (optional) ## Speak Event Types | Event | Description | Payload | |-------|-------------|---------| | `lesson.started` | User started a lesson | sessionId, userId, topic | | `lesson.completed` | User completed a lesson | sessionId, summary, score | | `lesson.abandoned` | User abandoned mid-lesson | sessionId, progress, reason | | `pronunciation.milestone` | Score threshold reached | userId, language, score | | `streak.achieved` | Learning streak milestone | userId, streakDays | | `level.up` | User advanced a level | userId, language, newLevel | | `subscription.changed` | Plan changed | userId, plan, action | ## Webhook Endpoint Setup ### Express.js Implementation ```typescript import express from 'express'; import crypto from 'crypto'; const app = express(); // IMPORTANT: Raw body needed for signature verification app.post('/webhooks/speak', express.raw({ type: 'application/json' }), async (req, res) => { const signature = req.headers['x-speak-signature'] as string; const timestamp = req.headers['x-speak-timestamp'] as string; const eventId = req.headers['x-speak-event-id'] as string; // Verify signature if (!verifySpeakSignature(req.body, signature, timestamp)) { console.error('Invalid webhook signature', { eventId }); return res.status(401).json({ error: 'Invalid signature' }); } // Check for duplicate (idempotency) if (await isEventProcessed(eventId)) { return res.status(200).json({ received: true, duplicate: true }); } const event = JSON.parse(req.body.toString()); try { await handleSpeakEvent(event); await markEventProcessed(eventId); res.status(200).json({ received: true }); } catch (error) { console.error('Webhook processing failed', { eventId, error }); // Return 500 to trigger Speak retry res.status(500).json({ error: 'Processing failed' }); } } ); ``` ## Signature Verification ```typescript function verifySpeakSignature( payload: Buffer, signature: string, timestamp: string ): boolean { const secret = process.env.SPEAK_WEBHOOK_SECRET!; // Reject old timestamps (replay attack protection) const timestampAge = Date.now() - parseInt(timestamp) * 1000; if (timestampAge > 300000) { // 5 minutes console.error('Webhook timestamp too old', { age: timestampAge }); return false; } // Reject future timestamps if (timestampAge < -60000) { // 1 minute tolerance console.error('Webhook timestamp in future'); return false; } // Compute expected signature const signedPayload = `${timestamp}.${payload.toString()}`; const expectedSignature = crypto .createHmac('sha256', secret) .update(signedPayload) .digest('hex'); // Timing-safe comparison try { return crypto.timingSafeEqual( Buffer.from(signature.replace('sha256=', '')), Buffer.from(expectedSignature) ); } catch { return false; } } ``` ## Event Handler Pattern ```typescript type SpeakEventType = | 'lesson.started' | 'lesson.completed' | 'lesson.abandoned' | 'pronunciation.milestone' | 'streak.achieved' | 'level.up' | 'subscription.changed'; interface SpeakEvent { id: string; type: SpeakEventType; data: Record; userId: string; createdAt: string; } // Type-safe event handlers interface LessonCompletedData { sessionId: string; topic: string; language: string; duration: number; averagePronunciationScore: number; vocabularyLearned: number; grammarPatternsUsed: string[]; } interface StreakAchievedData { streakDays: number; totalLessons: number; milestone: number; } const eventHandlers: Record Promise> = { 'lesson.started': async (data, userId) => { console.log(`User ${userId} started lesson: ${data.topic}`); await analytics.track('lesson_started', { userId, ...data }); }, 'lesson.completed': async (data: LessonCompletedData, userId) => { console.log(`User ${userId} completed lesson: ${data.topic}`); // Update user progress await db.users.update(userId, { totalLessons: { $inc: 1 }, vocabularyCount: { $inc: data.vocabularyLearned }, lastLessonAt: new Date(), }); // Award XP const xp = calculateXP(data); await gamification.awardXP(userId, xp); // Send completion notification await notifications.send(userId, { type: 'lesson_complete', title: 'Lesson Complete!', body: `Great job! You scored ${data.averagePronunciationScore}%`, }); await analytics.track('lesson_completed', { userId, ...data }); }, 'lesson.abandoned': async (data, userId) => { console.log(`User ${userId} abandoned lesson at ${data.progress}%`); // Track for engagement analysis await analytics.track('lesson_abandoned', { userId, ...data }); // Maybe send re-engagement notification later await scheduler.schedule('re_engagement', { userId, delayHours: 24, }); }, 'pronunciation.milestone': async (data, userId) => { console.log(`User ${userId} reached pronunciation milestone: ${data.score}`); await gamification.awardBadge(userId, `pronunciation_${data.score}`); await notifications.send(userId, { type: 'achievement', title: 'Pronunciation Milestone!', body: `You've reached ${data.score}% pronunciation accuracy!`, }); }, 'streak.achieved': async (data: StreakAchievedData, userId) => { console.log(`User ${userId} achieved ${data.streakDays} day streak`); await gamification.awardBadge(userId, `streak_${data.milestone}`); // Special rewards for milestone streaks if ([7, 30, 100, 365].includes(data.milestone)) { await rewards.grantStreakReward(userId, data.milestone); } }, 'level.up': async (data, userId) => { console.log(`User ${userId} leveled up to ${data.newLevel} in ${data.language}`); await db.users.update(userId, { [`levels.${data.language}`]: data.newLevel, }); await gamification.awardBadge(userId, `level_${data.language}_${data.newLevel}`); }, 'subscription.changed': async (data, userId) => { console.log(`User ${userId} subscription: ${data.action}`); await db.users.update(userId, { subscriptionPlan: data.plan, subscriptionStatus: data.action === 'cancelled' ? 'cancelled' : 'active', }); await analytics.track('subscription_changed', { userId, ...data }); }, }; async function handleSpeakEvent(event: SpeakEvent): Promise { const handler = eventHandlers[event.type]; if (!handler) { console.log(`Unhandled event type: ${event.type}`); return; } try { await handler(event.data, event.userId); console.log(`Processed ${event.type}: ${event.id}`); } catch (error) { console.error(`Failed to process ${event.type}: ${event.id}`, error); throw error; // Rethrow to trigger retry } } ``` ## Idempotency Handling ```typescript import { Redis } from 'ioredis'; const redis = new Redis(process.env.REDIS_URL); async function isEventProcessed(eventId: string): Promise { const key = `speak:event:${eventId}`; const exists = await redis.exists(key); return exists === 1; } async function markEventProcessed(eventId: string): Promise { const key = `speak:event:${eventId}`; // Keep for 7 days to handle delayed retries await redis.set(key, '1', 'EX', 86400 * 7); } ``` ## Webhook Testing ```bash # Use Speak CLI to send test events speak webhooks trigger lesson.completed \ --url http://localhost:3000/webhooks/speak \ --data '{"sessionId":"sess_123","topic":"greetings","score":85}' # Test with signature TIMESTAMP=$(date +%s) PAYLOAD='{"type":"lesson.completed","data":{"score":85}}' SIGNATURE=$(echo -n "${TIMESTAMP}.${PAYLOAD}" | openssl dgst -sha256 -hmac "$SPEAK_WEBHOOK_SECRET" | cut -d' ' -f2) curl -X POST http://localhost:3000/webhooks/speak \ -H "Content-Type: application/json" \ -H "X-Speak-Signature: sha256=${SIGNATURE}" \ -H "X-Speak-Timestamp: ${TIMESTAMP}" \ -H "X-Speak-Event-Id: test_$(uuidgen)" \ -d "${PAYLOAD}" ``` ## Local Development with ngrok ```bash # Expose local server ngrok http 3000 # Register webhook URL in Speak dashboard # URL: https://your-ngrok-url.ngrok.io/webhooks/speak # Test events will now hit your local server ``` ## Output - Secure webhook endpoint - Signature validation enabled - Event handlers implemented - Replay attack protection active - Idempotency for duplicate prevention ## Error Handling | Issue | Cause | Solution | |-------|-------|----------| | Invalid signature | Wrong secret | Verify webhook secret | | Timestamp rejected | Clock drift | Check server time sync | | Duplicate events | Missing idempotency | Implement event ID tracking | | Handler timeout | Slow processing | Use async queue | | Event not recognized | New event type | Add handler or log | ## Resources - [Speak Webhooks Guide](https://developer.speak.com/docs/webhooks) - [Webhook Security Best Practices](https://developer.speak.com/docs/webhooks/security) - [Event Reference](https://developer.speak.com/docs/events) ## Next Steps For performance optimization, see `speak-performance-tuning`.

Skill file: plugins/saas-packs/speak-pack/skills/speak-webhooks-events/SKILL.md