Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Implement Customer.io webhook and reporting event handling. Use when processing email delivery events, click/open tracking, bounce handling, or streaming to a data warehouse. Trigger: "customer.io webhook", "customer.io events", "customer.io delivery status", "customer.io bounces", "customer.io open tracking".
.claude/skills/jeremylongshore-customerio-webhooks-events/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 15% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 21% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 54% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 78% | 0% |
| case-11 | ✗→✓ | ▲ Improved | 144% | 0% |
Implement Customer.io reporting webhook handling: receive real-time delivery events (sent, delivered, opened, clicked, bounced, complained, unsubscribed), verify HMAC-SHA256 signatures, process events reliably with queuing, and stream to a data warehouse.
Customer.io Your Server Data Warehouse
────────── ─────────── ──────────────
Email sent → POST /webhooks/cio → Verify signature
Email opened → POST /webhooks/cio → Parse event type
Link clicked → POST /webhooks/cio → Route to handler → INSERT INTO events
Email bounced → POST /webhooks/cio → Suppress userConfigure at: Data & Integrations > Integrations > Reporting Webhooks
typescript// types/customerio-webhooks.ts // Customer.io reporting webhook event metrics type CioMetric = | "sent" // Message sent to delivery provider | "delivered" // Delivery provider confirmed receipt | "opened" // Recipient opened the email | "clicked" // Recipient clicked a link | "converted" // Recipient completed a conversion goal | "bounced" // Email bounced (hard or soft) | "spammed" // Recipient marked as spam | "unsubscribed" // Recipient unsubscribed | "dropped" // Message dropped (suppressed, invalid) | "deferred" // Delivery temporarily deferred | "failed"; // Delivery failed interface CioWebhookEvent { // Event metadata event_id: string; metric: CioMetric; timestamp: number; // Unix seconds // Recipient info customer_id: string; email_address?: string; // Message info subject?: string; template_id?: number; campaign_id?: number; broadcast_id?: number; action_id?: number; // Delivery details delivery_id?: string; // Link tracking (for "clicked" events) href?: string; link_id?: number; }
typescript// routes/webhooks/customerio.ts import { createHmac, timingSafeEqual } from "crypto"; import { Router, Request, Response } from "express"; const WEBHOOK_SECRET = process.env.CUSTOMERIO_WEBHOOK_SECRET!; function verifySignature(rawBody: Buffer, signature: string): boolean { const expected = createHmac("sha256", WEBHOOK_SECRET) .update(rawBody) .digest("hex"); try { return timingSafeEqual( Buffer.from(signature, "utf-8"), Buffer.from(expected, "utf-8") ); } catch { return false; } } const router = Router(); // IMPORTANT: Use raw body parser for this route — JSON parsing breaks signature verification router.post("/webhooks/customerio", (req: Request, res: Response) => { const signature = req.headers["x-cio-signature"] as string; const rawBody = (req as any).rawBody as Buffer; if (!signature || !rawBody) { res.status(401).json({ error: "Missing signature" }); return; } if (!verifySignature(rawBody, signature)) { console.error("CIO webhook: invalid signature"); res.status(401).json({ error: "Invalid signature" }); return; } const event: CioWebhookEvent = JSON.parse(rawBody.toString()); // Respond 200 immediately — process async to avoid timeouts res.sendStatus(200); // Process event asynchronously handleWebhookEvent(event).catch((err) => console.error("Webhook processing failed:", err) ); });
typescript// services/customerio-webhook-handler.ts import { TrackClient, RegionUS } from "customerio-node"; const cio = new TrackClient( process.env.CUSTOMERIO_SITE_ID!, process.env.CUSTOMERIO_TRACK_API_KEY!, { region: RegionUS } ); // Deduplication set (use Redis in production) const processedEvents = new Set<string>(); async function handleWebhookEvent(event: CioWebhookEvent): Promise<void> { // Deduplicate by event_id if (processedEvents.has(event.event_id)) return; processedEvents.add(event.event_id); // Limit in-memory set size if (processedEvents.size > 100_000) { const iterator = processedEvents.values(); for (let i = 0; i < 50_000; i++) iterator.next(); // In production, use Redis with TTL instead } switch (event.metric) { case "bounced": await handleBounce(event); break; case "spammed": await handleSpamComplaint(event); break; case "unsubscribed": await handleUnsubscribe(event); break; case "opened": case "clicked": await handleEngagement(event); break; case "delivered": case "sent": await handleDelivery(event); break; default: console.log(`CIO webhook: ${event.metric} for ${event.customer_id}`); } } async function handleBounce(event: CioWebhookEvent): Promise<void> { console.warn(`BOUNCE: ${event.email_address} (user: ${event.customer_id})`); // Update user profile with bounce info await cio.identify(event.customer_id, { email_bounced: true, email_bounced_at: event.timestamp, last_bounce_delivery_id: event.delivery_id, }); // Suppress after hard bounce to protect sender reputation await cio.suppress(event.customer_id); } async function handleSpamComplaint(event: CioWebhookEvent): Promise<void> { console.error(`SPAM COMPLAINT: ${event.email_address}`); // Suppress immediately — spam complaints damage sender reputation await cio.suppress(event.customer_id); // Record for compliance await cio.identify(event.customer_id, { spam_complaint: true, spam_complaint_at: event.timestamp, }); } async function handleUnsubscribe(event: CioWebhookEvent): Promise<void> { await cio.identify(event.customer_id, { unsubscribed: true, unsubscribed_at: event.timestamp, }); } async function handleEngagement(event: CioWebhookEvent): Promise<void> { await cio.identify(event.customer_id, { last_email_engaged_at: event.timestamp, last_email_metric: event.metric, }); } async function handleDelivery(event: CioWebhookEvent): Promise<void> { // Log for analytics — lightweight processing console.log( `${event.metric}: ${event.delivery_id} to ${event.customer_id}` ); }
typescript// server.ts import express from "express"; import webhookRouter from "./routes/webhooks/customerio"; const app = express(); // Raw body parser for webhook signature verification // MUST be before any JSON body parser app.use( "/webhooks/customerio", express.raw({ type: "application/json" }), (req, _res, next) => { (req as any).rawBody = req.body; req.body = JSON.parse(req.body.toString()); next(); } ); // JSON parser for all other routes app.use(express.json()); app.use(webhookRouter); app.listen(3000, () => console.log("Webhook server on :3000"));
typescript// services/customerio-warehouse.ts import { BigQuery } from "@google-cloud/bigquery"; const bq = new BigQuery(); const dataset = bq.dataset("messaging"); const table = dataset.table("customerio_events"); async function streamToWarehouse(event: CioWebhookEvent): Promise<void> { await table.insert({ event_id: event.event_id, metric: event.metric, customer_id: event.customer_id, email_address: event.email_address, delivery_id: event.delivery_id, campaign_id: event.campaign_id, template_id: event.template_id, href: event.href, timestamp: new Date(event.timestamp * 1000).toISOString(), received_at: new Date().toISOString(), }); }
https://your-domain.com/webhooks/customerioCUSTOMERIO_WEBHOOK_SECRET| Issue | Solution | |-------|----------| | Invalid signature | Verify webhook secret matches dashboard value | | Duplicate events | Deduplicate by event_id (use Redis SET with TTL in production) | | Slow processing | Return 200 immediately, process async | | Missing events | Check endpoint is publicly accessible, verify IP allowlist |
After webhook setup, proceed to customerio-performance-tuning for optimization.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-16 | pass→pass | 11,480 | 10,895 | -5% | 1 | 1 | 0% | 2,190 | 4,641 | +112% | 0 | 0 | — |
case-01 | fail→pass | 20,474 | 13,267 | -35% | 1 | 1 | 0% | 4,672 | 5,352 | +15% | 0 | 0 | — |
case-02 | fail→pass | 19,307 | 13,487 | -30% | 1 | 1 | 0% | 4,556 | 5,493 | +21% | 0 | 0 | — |
case-03 | fail→pass | 15,702 | 12,222 | -22% | 1 | 1 | 0% | 3,410 | 5,247 | +54% | 0 | 0 | — |
case-04 | pass→pass | 11,777 | 7,667 | -35% | 1 | 1 | 0% | 2,262 | 3,982 | +76% | 0 | 0 | — |
case-05 | pass→pass | 13,642 | 12,296 | -10% | 1 | 1 | 0% | 2,886 | 5,008 | +74% | 0 | 0 | — |
case-06 | pass→pass | 10,644 | 11,388 | +7% | 1 | 1 | 0% | 2,153 | 4,872 | +126% | 0 | 0 | — |
case-07 | pass→pass | 5,906 | 3,237 | -45% | 1 | 1 | 0% | 1,100 | 3,113 | +183% | 0 | 0 | — |
case-08 | pass→pass | 14,660 | 8,397 | -43% | 1 | 1 | 0% | 2,784 | 3,855 | +38% | 0 | 0 | — |
case-09 | pass→pass | 13,587 | 3,875 | -71% | 1 | 1 | 0% | 2,546 | 3,197 | +26% | 0 | 0 | — |
case-10 | fail→pass | 15,263 | 11,414 | -25% | 1 | 1 | 0% | 2,547 | 4,522 | +78% | 0 | 0 | — |
case-15 | pass→pass | 14,986 | 10,208 | -32% | 1 | 1 | 0% | 2,709 | 4,574 | +69% | 0 | 0 | — |
case-11 | fail→pass | 9,034 | 8,053 | -11% | 1 | 1 | 0% | 1,617 | 3,948 | +144% | 0 | 0 | — |
case-12 | fail→pass | 15,314 | 10,003 | -35% | 1 | 1 | 0% | 2,657 | 4,358 | +64% | 0 | 0 | — |
case-13 | pass→pass | 11,432 | 8,207 | -28% | 1 | 1 | 0% | 2,291 | 3,942 | +72% | 0 | 0 | — |
case-14 | pass→pass | 11,821 | 7,696 | -35% | 1 | 1 | 0% | 2,371 | 3,980 | +68% | 0 | 0 | — |
case-17 | pass→pass | 9,173 | 3,847 | -58% | 1 | 1 | 0% | 1,705 | 3,140 | +84% | 0 | 0 | — |
case-18 | pass→pass | 10,232 | 6,287 | -39% | 1 | 1 | 0% | 1,897 | 3,647 | +92% | 0 | 0 | — |
case-19 | pass→pass | 10,107 | 5,415 | -46% | 1 | 1 | 0% | 1,864 | 3,499 | +88% | 0 | 0 | — |
case-20 | fail→pass | 9,353 | 2,637 | -72% | 1 | 1 | 0% | 1,621 | 2,947 | +82% | 0 | 0 | — |
case-21 | fail→pass | 9,317 | 3,627 | -61% | 1 | 1 | 0% | 1,752 | 3,182 | +82% | 0 | 0 | — |
case-22 | pass→pass | 8,432 | 4,965 | -41% | 1 | 1 | 0% | 1,423 | 3,398 | +139% | 0 | 0 | — |
case-23 | fail→pass | 15,375 | 8,799 | -43% | 1 | 1 | 0% | 2,668 | 4,111 | +54% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 23 cases were attempted. The headline lift of +39 percentage points is the difference between those two pass rates over the 23 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.