界面已本地化;技能介绍与说明文档按所选语言由 AI 自动翻译,代码、命令、路径和专业术语保留原文,以原作者文档为准。
探索技能
开发与工程 · SKILL.md

speak-webhooks-events

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 快照 · 自动分类 · 以原作者文档为准

技能说明

GitHub

name: speak-webhooks-events description: | 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

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

EventDescriptionPayload
lesson.startedUser started a lessonsessionId, userId, topic
lesson.completedUser completed a lessonsessionId, summary, score
lesson.abandonedUser abandoned mid-lessonsessionId, progress, reason
pronunciation.milestoneScore threshold reacheduserId, language, score
streak.achievedLearning streak milestoneuserId, streakDays
level.upUser advanced a leveluserId, language, newLevel
subscription.changedPlan changeduserId, plan, action

Webhook Endpoint Setup

Express.js Implementation

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

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

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
  }
}

Idempotency Handling

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);
}

Webhook Testing

# 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

# 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

IssueCauseSolution
Invalid signatureWrong secretVerify webhook secret
Timestamp rejectedClock driftCheck server time sync
Duplicate eventsMissing idempotencyImplement event ID tracking
Handler timeoutSlow processingUse async queue
Event not recognizedNew event typeAdd handler or log

Resources

Next Steps

For performance optimization, see speak-performance-tuning.

我的收藏