Customer.io Webhooks & Events

SkillDev tools

'Implement Customer.io webhook and reporting event handling.

Use Customer.io Webhooks & Events in Claude, ChatGPT or Ahel Desktop

Free. Sign in, add Customer.io Webhooks & Events and connect your AI. About a minute.

Also: Claude Code · Cursor · Codex

Then ask your AI: use the Customer.io Webhooks & Events skill

Details

Instructions available. Your AI can read the instructions. Execution depends on the setup they require.

Add Ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Customer.io Webhooks & EventsStart free

What this skill tells your AI

The instructions your AI receives, as published by jeremylongshore/tons-of-skills-marketplace in skills/.curated/customerio-webhooks-events/SKILL.md and read by Ahel’s review.

Output

  • A validated, signed, idempotent event/webhook flow with redacted observability and an owned retry policy.
  • A delivery receipt that records correlation ID, environment, event type, result, and consent-safe recovery decision.

Examples

Verify a webhook signature before parsing payload data, store only the opaque event ID for deduplication, and acknowledge within the provider timeout. For a failed processing attempt, retry with bounded backoff; send terminal failures to a protected queue and replay only after schema and consent checks pass.

Overview

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

Prerequisites

  • Public HTTPS endpoint for webhook receiver
  • Webhook signing key from Customer.io dashboard
  • Express or similar HTTP framework

Instructions

Step 1: Define Webhook Event Types

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

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

Step 5: Stream to Data Warehouse (BigQuery)

// 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(),
  });
}

Dashboard Configuration

  1. Go to Data & Integrations > Integrations > Reporting Webhooks
  2. Click Add Reporting Webhook
  3. Enter your endpoint URL: https://your-domain.com/webhooks/customerio
  4. Select events to receive (recommended: all for analytics)
  5. Copy the webhook signing key to CUSTOMERIO_WEBHOOK_SECRET

Error Handling

IssueSolution
Invalid signatureVerify webhook secret matches dashboard value
Duplicate eventsDeduplicate by event_id (use Redis SET with TTL in production)
Slow processingReturn 200 immediately, process async
Missing eventsCheck endpoint is publicly accessible, verify IP allowlist

Resources

Next Steps

After webhook setup, proceed to customerio-performance-tuning for optimization.

Signals

GitHub stars
3k
Forks
415
Last commit
Oct 2026
Advanced
Item type
skill
Key
customerio-webhooks-events
Source
github.com/jeremylongshore/tons-of-skills-marketplace