| 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
| 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
import express from 'express';
import crypto from 'crypto';
const app = express();
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;
if (!verifySpeakSignature(req.body, signature, timestamp)) {
console.error('Invalid webhook signature', { eventId });
return res.status(401).json({ error: 'Invalid signature' });
}
if (await isEventProcessed(eventId)) {
return res.status().({ : , : });
}
event = .(req..());
{
(event);
(eventId);
res.().({ : });
} (error) {
.(, { eventId, error });
res.().({ : });
}
}
);
Signature Verification
function verifySpeakSignature(
payload: Buffer,
signature: string,
timestamp: string
): boolean {
const secret = process.env.SPEAK_WEBHOOK_SECRET!;
const timestampAge = Date.now() - parseInt(timestamp) * 1000;
if (timestampAge > 300000) {
console.error('Webhook timestamp too old', { age: timestampAge });
return false;
}
if (timestampAge < -60000) {
console.error('Webhook timestamp in future');
return false;
}
const signedPayload = `${timestamp}.${payload.toString()}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
{
crypto.(
.(signature.(, )),
.(expectedSignature)
);
} {
;
}
}
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;
}
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, userId) => {
.();
analytics.(, { userId, ...data });
},
: (: , userId) => {
.();
db..(userId, {
: { : },
: { : data. },
: (),
});
xp = (data);
gamification.(userId, xp);
notifications.(userId, {
: ,
: ,
: ,
});
analytics.(, { userId, ...data });
},
: (data, userId) => {
.();
analytics.(, { userId, ...data });
scheduler.(, {
userId,
: ,
});
},
: (data, userId) => {
.();
gamification.(userId, );
notifications.(userId, {
: ,
: ,
: ,
});
},
: (: , userId) => {
.();
gamification.(userId, );
([, , , ].(data.)) {
rewards.(userId, data.);
}
},
: (data, userId) => {
.();
db..(userId, {
[]: data.,
});
gamification.(userId, );
},
: (data, userId) => {
.();
db..(userId, {
: data.,
: data. === ? : ,
});
analytics.(, { userId, ...data });
},
};
(): <> {
handler = eventHandlers[event.];
(!handler) {
.();
;
}
{
(event., event.);
.();
} (error) {
.(, error);
error;
}
}
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}`;
await redis.set(key, '1', 'EX', 86400 * 7);
}
Webhook Testing
speak webhooks trigger lesson.completed \
--url http://localhost:3000/webhooks/speak \
--data '{"sessionId":"sess_123","topic":"greetings","score":85}'
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
ngrok http 3000
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
Next Steps
For performance optimization, see speak-performance-tuning.