Implement Customer.io webhook handling.
Use when processing delivery events, handling callbacks,
or integrating Customer.io event streams.
Trigger with phrases like "customer.io webhook", "customer.io events",
"customer.io callback", "customer.io delivery status".
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.
How Reporting Webhooks Work
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 user
Configure at: Data & Integrations > Integrations > Reporting Webhooks
Step 2: Webhook Handler with Signature Verification
// 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)
);
});
Step 3: Event Router and Handlers
// 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}`
);
}
Step 4: Express Server Setup
// 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"));