Customer.io Local Dev Loop

SkillDev tools

'Configure Customer.io local development workflow.

Use Customer.io Local Dev Loop in Claude, ChatGPT or Ahel Desktop

Free. Sign in, add Customer.io Local Dev Loop and connect your AI. About a minute.

Also: Claude Code · Cursor · Codex

Then ask your AI: use the Customer.io Local Dev Loop 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 Local Dev LoopStart free

What this skill tells your AI

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

Output

  • A small, test-backed local change using a non-production workspace and synthetic recipient data.
  • A reviewed deployment/revert path that preserves production campaigns, consent, and contact data.

Examples

Use a development workspace and a synthetic profile such as test-user-001; validate the event schema locally, run the focused test, and inspect the resulting campaign behavior before committing. Never point local experiments at production segments or use real recipient data to debug a template.

Overview

Set up an efficient local development workflow for Customer.io: environment isolation via separate workspaces, a dry-run client for safe development, test mocks for unit tests, and prefixed events that never pollute production data.

Prerequisites

  • customerio-node installed
  • Separate Customer.io workspace for development (recommended — free workspaces available)
  • dotenv or similar for environment variable loading

Instructions

Step 1: Environment Configuration

# .env.development
CUSTOMERIO_SITE_ID=dev-site-id
CUSTOMERIO_TRACK_API_KEY=dev-track-key
CUSTOMERIO_APP_API_KEY=dev-app-key
CUSTOMERIO_REGION=us
CUSTOMERIO_DRY_RUN=false
CUSTOMERIO_EVENT_PREFIX=dev_

# .env.test
CUSTOMERIO_SITE_ID=not-needed
CUSTOMERIO_TRACK_API_KEY=not-needed
CUSTOMERIO_APP_API_KEY=not-needed
CUSTOMERIO_DRY_RUN=true
CUSTOMERIO_EVENT_PREFIX=test_

Step 2: Environment-Aware Client

// lib/customerio-dev.ts
import { TrackClient, APIClient, RegionUS, RegionEU } from "customerio-node";

interface CioConfig {
  siteId: string;
  trackApiKey: string;
  appApiKey: string;
  region: typeof RegionUS | typeof RegionEU;
  dryRun: boolean;
  eventPrefix: string;
}

function loadConfig(): CioConfig {
  return {
    siteId: process.env.CUSTOMERIO_SITE_ID ?? "",
    trackApiKey: process.env.CUSTOMERIO_TRACK_API_KEY ?? "",
    appApiKey: process.env.CUSTOMERIO_APP_API_KEY ?? "",
    region: process.env.CUSTOMERIO_REGION === "eu" ? RegionEU : RegionUS,
    dryRun: process.env.CUSTOMERIO_DRY_RUN === "true",
    eventPrefix: process.env.CUSTOMERIO_EVENT_PREFIX ?? "",
  };
}

export class DevTrackClient {
  private client: TrackClient | null = null;
  private config: CioConfig;
  private log: typeof console.log;

  constructor() {
    this.config = loadConfig();
    this.log = console.log.bind(console);
    if (!this.config.dryRun) {
      this.client = new TrackClient(
        this.config.siteId,
        this.config.trackApiKey,
        { region: this.config.region }
      );
    }
  }

  async identify(userId: string, attributes: Record<string, any>) {
    const prefixedId = `${this.config.eventPrefix}${userId}`;
    if (this.config.dryRun) {
      this.log("[DRY RUN] identify:", prefixedId, attributes);
      return;
    }
    return this.client!.identify(prefixedId, attributes);
  }

  async track(userId: string, eventName: string, data?: Record<string, any>) {
    const prefixedId = `${this.config.eventPrefix}${userId}`;
    const prefixedEvent = `${this.config.eventPrefix}${eventName}`;
    if (this.config.dryRun) {
      this.log("[DRY RUN] track:", prefixedId, prefixedEvent, data);
      return;
    }
    return this.client!.track(prefixedId, {
      name: prefixedEvent,
      data,
    });
  }

  async suppress(userId: string) {
    const prefixedId = `${this.config.eventPrefix}${userId}`;
    if (this.config.dryRun) {
      this.log("[DRY RUN] suppress:", prefixedId);
      return;
    }
    return this.client!.suppress(prefixedId);
  }
}

Step 3: Test Mocks for Unit Tests

// __mocks__/customerio-node.ts (for vitest/jest auto-mocking)
import { vi } from "vitest";

export const TrackClient = vi.fn().mockImplementation(() => ({
  identify: vi.fn().mockResolvedValue(undefined),
  track: vi.fn().mockResolvedValue(undefined),
  trackAnonymous: vi.fn().mockResolvedValue(undefined),
  suppress: vi.fn().mockResolvedValue(undefined),
  destroy: vi.fn().mockResolvedValue(undefined),
  mergeCustomers: vi.fn().mockResolvedValue(undefined),
}));

export const APIClient = vi.fn().mockImplementation(() => ({
  sendEmail: vi.fn().mockResolvedValue({ delivery_id: "mock-delivery-123" }),
  sendPush: vi.fn().mockResolvedValue({ delivery_id: "mock-push-456" }),
  triggerBroadcast: vi.fn().mockResolvedValue(undefined),
}));

export const RegionUS = "us";
export const RegionEU = "eu";
export const SendEmailRequest = vi.fn().mockImplementation((data) => data);
export const SendPushRequest = vi.fn().mockImplementation((data) => data);

Step 4: Integration Test with Real API

// tests/customerio.integration.test.ts
import { describe, it, expect, afterAll } from "vitest";
import { TrackClient, RegionUS } from "customerio-node";

const TEST_PREFIX = `test_${Date.now()}_`;
const testUserIds: string[] = [];

const cio = new TrackClient(
  process.env.CUSTOMERIO_SITE_ID!,
  process.env.CUSTOMERIO_TRACK_API_KEY!,
  { region: RegionUS }
);

function testUserId(label: string): string {
  const id = `${TEST_PREFIX}${label}`;
  testUserIds.push(id);
  return id;
}

describe("Customer.io Integration", () => {
  afterAll(async () => {
    // Clean up all test users
    for (const id of testUserIds) {
      await cio.suppress(id).catch(() => {});
      await cio.destroy(id).catch(() => {});
    }
  });

  it("should identify a user", async () => {
    const id = testUserId("identify");
    await expect(
      cio.identify(id, { email: `${id}@test.example.com` })
    ).resolves.not.toThrow();
  });

  it("should track an event", async () => {
    const id = testUserId("track");
    await cio.identify(id, { email: `${id}@test.example.com` });
    await expect(
      cio.track(id, { name: "test_event", data: { step: 1 } })
    ).resolves.not.toThrow();
  });

  it("should reject invalid credentials", async () => {
    const badClient = new TrackClient("bad-id", "bad-key", {
      region: RegionUS,
    });
    await expect(
      badClient.identify("x", { email: "x@test.com" })
    ).rejects.toThrow();
  });
});

Run integration tests only against your dev workspace:

# Load dev env and run integration tests
npx dotenv -e .env.development -- npx vitest run tests/customerio.integration.test.ts

Step 5: Dev Scripts

// package.json scripts
{
  "scripts": {
    "cio:verify": "dotenv -e .env.development -- tsx scripts/verify-customerio.ts",
    "cio:test": "dotenv -e .env.development -- vitest run tests/customerio.integration.test.ts",
    "cio:test:dry": "CUSTOMERIO_DRY_RUN=true vitest run tests/customerio"
  }
}

Workspace Isolation Strategy

EnvironmentWorkspace NameEvent PrefixDry Run
Unit tests(mocked)test_true
Integration testsmyapp-devinttest_false
Stagingmyapp-staging(none)false
Productionmyapp-prod(none)false

Error Handling

ErrorCauseSolution
Dev events in productionWrong .env file loadedVerify NODE_ENV and env file path
Mock not interceptingImport order issueMock customerio-node before importing your client module
Test user pollutionNo cleanupAlways suppress + destroy test users in afterAll

Resources

Next Steps

After setting up local dev, proceed to customerio-sdk-patterns for production-ready patterns.

Signals

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