协作头脑风暴
brainstorming
在实现前澄清需求、探索设计选择,并形成清晰的开发方向。
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 notificat…
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
Securely handle Speak webhooks with signature validation for language learning event notifications.
| 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 |
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' });
}
}
);
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;
}
}
type SpeakEventType =
| 'lesson.started'
| 'lesson.completed'
| 'lesson.abandoned'
| 'pronunciation.milestone'
| 'streak.achieved'
| 'level.up'
| 'subscription.changed';
interface SpeakEvent {
id: string;
type: SpeakEventType;
data: Record<string, any>;
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<SpeakEventType, (data: any, userId: string) => Promise<void>> = {
'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<void> {
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
}
}
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
async function isEventProcessed(eventId: string): Promise<boolean> {
const key = `speak:event:${eventId}`;
const exists = await redis.exists(key);
return exists === 1;
}
async function markEventProcessed(eventId: string): Promise<void> {
const key = `speak:event:${eventId}`;
// Keep for 7 days to handle delayed retries
await redis.set(key, '1', 'EX', 86400 * 7);
}
# 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}"
# 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
| 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 |
For performance optimization, see speak-performance-tuning.