Claude-code-plugins-plus klaviyo-webhooks-events
install
source · Clone the upstream repo
git clone https://github.com/jeremylongshore/claude-code-plugins-plus-skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/jeremylongshore/claude-code-plugins-plus-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/saas-packs/klaviyo-pack/skills/klaviyo-webhooks-events" ~/.claude/skills/jeremylongshore-claude-code-plugins-plus-klaviyo-webhooks-events && rm -rf "$T"
manifest:
plugins/saas-packs/klaviyo-pack/skills/klaviyo-webhooks-events/SKILL.mdsource content
Klaviyo Webhooks & Events
Overview
Set up Klaviyo webhooks with HMAC-SHA256 signature verification, event routing, idempotency handling, and the Webhooks API for programmatic subscription management.
Prerequisites
- Klaviyo account with webhooks enabled
- HTTPS endpoint accessible from internet
- API key with scopes:
,webhooks:readwebhooks:write - Redis or database for idempotency (recommended)
Klaviyo Webhook Architecture
Klaviyo webhooks fire when specific topics occur in your account. Each webhook is signed with a secret key using HMAC-SHA256.
| Topic Category | Example Topics |
|---|---|
| Profile | , , |
| List | , |
| Segment | , |
| Campaign | , |
| Flow | , |
| Event | Custom metric events |
Instructions
Step 1: Create a Webhook via API
import { ApiKeySession, WebhooksApi } from 'klaviyo-api'; const session = new ApiKeySession(process.env.KLAVIYO_PRIVATE_KEY!); const webhooksApi = new WebhooksApi(session); // Create a webhook subscription const webhook = await webhooksApi.createWebhook({ data: { type: 'webhook', attributes: { name: 'Profile Updates', endpointUrl: 'https://your-app.com/webhooks/klaviyo', // The secret used for HMAC-SHA256 signing // Store this as KLAVIYO_WEBHOOK_SIGNING_SECRET description: 'Receives profile create/update events', }, relationships: { webhookTopics: { data: [ { type: 'webhook-topic', id: 'profile.created' }, { type: 'webhook-topic', id: 'profile.updated' }, ], }, }, }, }); console.log('Webhook ID:', webhook.body.data.id); // Save the signing secret from the response
Step 2: Signature Verification
// src/klaviyo/webhook-verify.ts import crypto from 'crypto'; /** * Verify Klaviyo webhook HMAC-SHA256 signature. * Klaviyo sends the signature in the webhook-signature header. */ export function verifyWebhookSignature( rawBody: Buffer | string, signature: string, secret: string ): boolean { if (!signature || !secret) return false; const expectedSignature = crypto .createHmac('sha256', secret) .update(typeof rawBody === 'string' ? rawBody : rawBody.toString()) .digest('base64'); try { return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expectedSignature) ); } catch { return false; } }
Step 3: Express Webhook Handler
import express from 'express'; import { verifyWebhookSignature } from './klaviyo/webhook-verify'; const app = express(); // CRITICAL: Use raw body parser for signature verification app.post('/webhooks/klaviyo', express.raw({ type: 'application/json' }), async (req, res) => { // 1. Verify signature const signature = req.headers['webhook-signature'] as string; if (!verifyWebhookSignature( req.body, signature, process.env.KLAVIYO_WEBHOOK_SIGNING_SECRET! )) { console.warn('[Webhook] Invalid signature rejected'); return res.status(401).json({ error: 'Invalid signature' }); } // 2. Parse event const event = JSON.parse(req.body.toString()); // 3. Check idempotency (prevent duplicate processing) const eventId = event.id || event.data?.id; if (eventId && await isAlreadyProcessed(eventId)) { return res.status(200).json({ status: 'already_processed' }); } // 4. Route to handler try { await routeWebhookEvent(event); if (eventId) await markProcessed(eventId); res.status(200).json({ received: true }); } catch (error) { console.error('[Webhook] Processing failed:', error); res.status(500).json({ error: 'Processing failed' }); } } );
Step 4: Event Router
// src/klaviyo/webhook-router.ts type WebhookHandler = (data: any) => Promise<void>; const handlers: Record<string, WebhookHandler> = { 'profile.created': async (data) => { const profile = data.attributes; console.log(`New profile: ${profile.email}`); // Sync to your database, trigger welcome flow, etc. await db.users.upsert({ email: profile.email, firstName: profile.firstName, klaviyoProfileId: data.id, }); }, 'profile.updated': async (data) => { const profile = data.attributes; console.log(`Updated profile: ${profile.email}`); await db.users.update({ where: { klaviyoProfileId: data.id }, data: { firstName: profile.firstName, lastName: profile.lastName }, }); }, 'list.member.added': async (data) => { console.log(`Profile ${data.relationships.profile.data.id} added to list ${data.relationships.list.data.id}`); }, 'campaign.sent': async (data) => { console.log(`Campaign sent: ${data.attributes.name}`); await analytics.track('campaign_sent', { campaignId: data.id }); }, }; export async function routeWebhookEvent(event: any): Promise<void> { const topic = event.type || event.topic; const handler = handlers[topic]; if (!handler) { console.log(`[Webhook] Unhandled topic: ${topic}`); return; } await handler(event.data || event); }
Step 5: Idempotency with Redis
// src/klaviyo/webhook-idempotency.ts import { Redis } from 'ioredis'; const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379'); const TTL_SECONDS = 86400 * 7; // 7 days export async function isAlreadyProcessed(eventId: string): Promise<boolean> { const key = `klaviyo:webhook:${eventId}`; return (await redis.exists(key)) === 1; } export async function markProcessed(eventId: string): Promise<void> { const key = `klaviyo:webhook:${eventId}`; await redis.setex(key, TTL_SECONDS, new Date().toISOString()); }
Step 6: List and Manage Webhooks
// List all webhooks const webhooks = await webhooksApi.getWebhooks(); for (const wh of webhooks.body.data) { console.log(`${wh.attributes.name}: ${wh.attributes.endpointUrl}`); } // Get webhook topics (available event types) const topics = await webhooksApi.getWebhookTopics(); for (const topic of topics.body.data) { console.log(`Topic: ${topic.id} - ${topic.attributes.description}`); } // Delete a webhook await webhooksApi.deleteWebhook({ id: 'WEBHOOK_ID' });
Testing Webhooks Locally
# 1. Start your app npm run dev # localhost:3000 # 2. Expose via ngrok ngrok http 3000 # 3. Register ngrok URL as webhook endpoint in Klaviyo # https://abc123.ngrok.io/webhooks/klaviyo # 4. Trigger an event (e.g., create a profile) and watch your logs
Error Handling
| Issue | Cause | Solution |
|---|---|---|
| Invalid signature | Wrong signing secret | Verify secret matches webhook creation response |
| Duplicate events | No idempotency | Track event IDs in Redis/DB |
| Webhook timeout | Slow processing | Return 200 immediately, process async |
| Missing events | Wrong topics subscribed | Check webhook topic subscriptions |
| Body parse error | Using JSON body parser | Must use for signature verification |
Resources
Next Steps
For performance optimization, see
klaviyo-performance-tuning.