Agent skill

Customer.io Webhooks Skill

Receive and verify Customer.io Reporting Webhooks. Use when setting up Customer.io webhook handlers, debugging X-CIO-Signature verification, or handling messaging events like email delivered, email opened, email clicked, email bounced, sms sent, push delivered, or customer unsubscribed.

Install this skill

npx skills add hookdeck/webhook-skills --skill customerio-webhooks


When to Use This Skill

  • How do I receive Customer.io Reporting Webhooks?
  • How do I verify Customer.io webhook signatures (X-CIO-Signature)?
  • How do I handle email delivered, opened, clicked, or bounced events?
  • Why is my Customer.io webhook signature verification failing?
  • How do I identify events by object_type + metric instead of a dotted name?

How Customer.io Webhooks Are Different

  • No single event-name string. Each POST is one event object. Identify it by the object_type (customer, email, push, sms, in_app, slack, webhook, whatsapp) plus the metric (sent, delivered, opened, clicked, bounced, dropped, spammed, failed, converted, unsubscribed, …). There is no email.opened field — you build that pair yourself from object_type + metric.
  • Custom signature scheme (not Standard Webhooks). The signed string is v0:<X-CIO-Timestamp>:<raw body>, HMAC-SHA256, hex digest. There are no webhook-id / webhook-signature headers.
  • No verification SDK. customerio-node and the customerio pip package are API clients only — they do not ship webhook signature helpers. Verify manually (shown below).
  • Strict 4-second timeout. Return 2xx within 4 seconds or Customer.io retries with exponential backoff for 7 days and backlogs later events. Do heavy work asynchronously.

Verification (core)

Build the string v0:<X-CIO-Timestamp>:<raw body> (version is always v0), HMAC-SHA256 it with your webhook signing key, and hex-compare against X-CIO-Signature. Use the raw, unmodified body — don't JSON.parse first.

const crypto = require('crypto');

function verifyCustomerIoWebhook(rawBody, timestamp, signature, signingKey) {
  if (!timestamp || !signature) return false;

  // Signed content: "v0:<timestamp>:<raw body>". Feed the raw body straight
  // into the HMAC so it is never re-encoded.
  const hmac = crypto.createHmac('sha256', signingKey);
  hmac.update(`v0:${timestamp}:`);
  hmac.update(rawBody); // Buffer or string of the unmodified request body
  const expected = hmac.digest('hex');

  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature, 'hex'),
      Buffer.from(expected, 'hex')
    );
  } catch {
    return false; // length mismatch / non-hex signature
  }
}

For complete handlers with route wiring, event dispatch, and tests, see:

Common Event Types (object_type + metric)

object_typemetricFires when
emailsentMessage handed to the sending provider
emaildeliveredRecipient's mail server accepted the message
emailopenedRecipient opened the email
emailclickedRecipient clicked a tracked link (data.href, data.link_id)
emailbouncedDelivery hard/soft bounced
emaildroppedCustomer.io dropped before sending (suppression, etc.)
emailspammedRecipient marked the email as spam
emailconvertedRecipient completed the campaign conversion goal
smssent / delivered / clickedSMS lifecycle
pushsent / delivered / openedPush lifecycle
customersubscribed / unsubscribedSubscription state changed

The same metric appears across object_types — always branch on both. See references/overview.md for the full matrix.

Environment Variables

# Signing key from the Reporting Webhooks integration page (account settings)
CUSTOMERIO_WEBHOOK_SIGNING_KEY=your_signing_key

Local Development

# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 customerio --path /webhooks/customerio

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Aug 1, 2026

View on GitHub →