Customer.io

@crvouga/mockingbird-service-customerio

Stateful mock of Customer.io: Segment-compatible CDP (identify/track/batch, SDK drop-in), App API transactional email/SMS/inbox sends with an outbox, message catalog, link-click tracking, and signed reporting webhooks.

Work in progress. Usable, but incomplete: operations, response shapes and options can still change between releases. Pin an exact version. Check the README's "Deliberately not modelled" notes before relying on it.
Work in progressMarketing Runs in the browser10 of 10 operationsIn-process fetchNode servermockingbird-customerio
$npm install -D @crvouga/mockingbird-service-customerio

Playground

Requests go to the real mock running in this tab. State persists across requests, and the journal shows what it received. A green dot marks operations whose sample succeeds as-is; others need ids from earlier responses.

Loads the real @crvouga/mockingbird-service-customerio into this tab on first send
https://api.customer.io

`Basic base64(<write key>:)` (the CDP source write key as the username).

Send a request to see the mock's response.

Operations

10 of 10 operations in the vendored contract are mocked. Hand-authored from Customer.io's CDP (Segment spec) and App API reference, trimmed to what our consumer sends: customer-io.client.service.ts / notification-adapter.ts / transactional-email-adapter.ts (backend), transactional-client.ts (notification-processor), customerio-trigger-name-validator.ts (notification-service) and mobile-links-click-reporter.service.ts.

MethodPathOperationStatus
POST/v1/identifyCdpIdentify
POST/v1/trackCdpTrack
POST/v1/batchCdpBatchWhat `@customerio/cdp-analytics-node` posts: `{batch: [...]}`.
POST/v1/send/emailSendEmail
POST/v1/send/smsSendSms
POST/v1/send/inbox_messageSendInboxMessage
GET/v1/transactionalListTransactionalMessages
GET/v1/transactional/{transactional_id}GetTransactionalMessage
GET/click/{linkId}FollowClickA browser following a tracked link; redirects to the original URL.
POST/click/{linkId}ReportClickLink-tracking click report on the tracking domain (our backend posts it when a smart

Documentation

The package README, the same file that ships in the npm tarball.Edit on GitHub

Stateful mock of Customer.io for test suites, serving all three hosts our code talks to from one process: the Segment-compatible CDP (identify, track, batch, exactly as @customerio/cdp-analytics-node posts them), the App API transactional sends (email, SMS, inbox message) and message catalog, and the link-tracking click endpoint. Sends land in an outbox a suite asserts on (and reads links out of); reporting events (unsubscribed, subscribed, spammed, subscription preferences, clicked) are posted to our reporting webhook, signed the way Customer.io signs them.

  • Operation coverage: SUPPORT.md
  • The contract (openapi.yaml) is hand-authored from Customer.io's CDP (Segment spec) and App API references, trimmed to what our consumers send.

Install

Shell
npm install -D @crvouga/mockingbird-service-customerio

ESM only. Node >= 22 or Bun >= 1.2. No native dependencies. Serve it with npx mockingbird-customerio serve, createServer from ./server (Node), or createRuntime with any Fetch server.

Usage

The app hardcodes Customer.io's hosts per region (customer-io.config.ts, seam G-Y1): once they are env-driven, point the CDP host (the SDK's host), the App API host and the link tracking domain at the mock. Customer.io only runs when CUSTOMERIO_RUNTIME_ENABLED is on and the stage is in CUSTOMERIO_ALLOWED_STAGES.

Shell
npx mockingbird-customerio serve --port 8810 \
  --webhook-url http://127.0.0.1:3000/v1/customer-io/reporting-webhook \
  --webhook-secret "$CUSTOMERIO_REPORTING_WEBHOOK_SIGNING_KEY"
JavaScript
import { Analytics } from "@customerio/cdp-analytics-node"
import { createServer } from "@crvouga/mockingbird-service-customerio/server"

const cio = await createServer({
  webhooks: { url: "http://127.0.0.1:3000/v1/customer-io/reporting-webhook", secret: "k".repeat(32) },
})
const analytics = new Analytics({ writeKey: "wk", host: cio.url, maxEventsInBatch: 1 })
analytics.identify({ userId: "42", traits: { email: "ada@example.com" } })
await analytics.closeAndFlush()

// The member unsubscribes in Customer.io: the signed reporting event reaches the backend.
await fetch(`${cio.url}/__admin/reporting-events`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ metric: "unsubscribed", userId: "42", objectType: "customer" }),
})

Without the SDK, drive the mock directly and read back what the app sent:

TypeScript
import { createServer } from "@crvouga/mockingbird-service-customerio/server"

const cio = await createServer()
await fetch(`${cio.url}/v1/identify`, {
  method: "POST",
  headers: { "content-type": "application/json", authorization: `Basic ${btoa("wk:")}` },
  body: JSON.stringify({ userId: "42", traits: { email: "ada@example.com" } }),
})
const profiles = await (await fetch(`${cio.url}/__admin/profiles`)).json()
const deliveries = await (await fetch(`${cio.url}/__admin/outbox?userId=42`)).json()
await cio.close()

Routes

Route Behaviour
POST /v1/identify, /v1/track, /v1/batch CDP, Authorization: Basic base64(<write key>:) (else 401 {error}). Segment events (batch of identify / track); each needs userId or anonymousId (else 400 {error}, which the SDK does not retry). → {success: true}. Identify merges traits into the profile (email, unsubscribed); a repeated messageId is recorded as a duplicate and not applied.
POST /v1/send/email, /v1/send/sms, /v1/send/inbox_message App API, Authorization: Bearer <app key> (else 401 {meta: {error}}). {transactional_message_id (id or trigger name), identifiers: {id | email | cio_id}, to?, from?, subject?, message_data?, send_to_unsubscribed?, tracked?, disable_message_retention?, headers?, attachments?}{delivery_id, queued_at}. Validation errors are 400 {meta: {error}}. With strictMessages, an id that is neither a catalog id nor a trigger name is 400 {meta: {error: "transactional_message_id not found"}}. A profile that unsubscribed (or switched the channel off) gets a suppressed delivery unless send_to_unsubscribed. disable_message_retention keeps no message_data in the outbox. tracked: true rewrites every URL in message_data to <trackingBase>/click/<linkId>. to defaults to the profile's email (email) or phone trait (SMS).
GET /v1/transactional {messages: [{id, name, trigger_name, description, send_to_unsubscribed, link_tracking, …}]}, no pagination. Seeded with geviti_<key> for every legacy email key our backend has, plus geviti_inbox_message and geviti_playground_notification (ids 1–21).
GET /v1/transactional/{id} {message: {...}} by id or trigger name, or 404.
POST /click/{linkId} Our backend's click report (unauthenticated) → 200, counts the click and posts a clicked reporting event. Unknown link: plain-text 404.
GET /click/{linkId} A browser following a tracked link → 302 to the original URL.

Reporting webhook

POST <webhook-url> with x-cio-timestamp: <unix seconds> and x-cio-signature: <hex HMAC-SHA256(secret, "v0:<timestamp>:<body>")> (wall-clock timestamps). Body: {event_id, object_type, metric, timestamp, data: {identifiers: {id, email, cio_id}, customer_id, email_address, delivery_id?, transactional_message_id?, href?, link_id?, content?}}; content is the JSON string of subscription preferences. timestamp never runs ahead of wall-clock time (our receiver rejects events > 5 min in the future). Retries, GET /__admin/webhooks, …/events, …/replay, …/flush and PUT /__admin/webhook-endpoints work as usual.

Admin (beyond the standard contract)

Route Effect
GET /__admin/outbox?to=&since=&channel=&transactional_message_id=&userId= Transactional deliveries, oldest first (GET /__admin/outbox/:id for one): channel, to, identifiers, subject, messageData, links, tracked, state (sent / suppressed), clicks, attachments (filenames).
POST /__admin/reporting-events {metric, userId? | email? | deliveryId?, objectType?, preferences?: {topics?, channels?}}: apply it to the profile (unsubscribed, subscribed, spammed, cio_subscription_preferences_changed) and post the signed event. Any other metric (delivered, opened, bounced, …) is posted as-is.
GET /__admin/cdp/events?userId=&type=&event= CDP calls received (with duplicate).
GET /__admin/profiles, GET /__admin/profiles/:id Profiles (traits, unsubscribed, channelsOff).
GET|PUT /__admin/transactional Read or replace the workspace's transactional messages ([{id?, name?, trigger_name, link_tracking?, send_to_unsubscribed?}]).
GET|PUT /__admin/settings {strictMessages?, trackingBase?, keys?} (keys restricts accepted write / App API keys).

Fault presets (POST /__admin/faults {"preset": "<name>", "count"?: n}; GET /__admin/faults/presets): transactional_message_missing (400 meta.error: trigger_name_missing, then fallback), transactional_404, request_timeout_408 (ambiguous: the reservation is kept), server_error (500, ambiguous), accepted_but_500 (queued, then 500), rate_limited (429, definite), invalid_app_key, send_drop (socket closes mid-request: ambiguous), cdp_unavailable (503; the SDK retries), cdp_bad_request (400; no retry), cdp_slow (15 s), transactional_list_unavailable, omit_trigger_names (the validator falls back to per-id reads), webhook_duplicate, webhook_drop. ECONNREFUSED (a definite failure) is a stopped mock, not a preset.

Namespaces

x-mockingbird-namespace, a /ns/<name> prefix on a host, or by key: the CDP write key (Basic username) or the App API key (Bearer) through PUT /__admin/credentials {"credentials": {"<key>": "<namespace>"}}. The click endpoint carries no credential: use the header or prefix.

Deliberately not modelled

  • Rendering: templates are not rendered; the outbox holds message_data, not HTML.
  • Campaigns, segments, journeys, broadcasts and the Track API (track.customer.io).
  • CDP page, screen, group and alias calls (our consumers send none).
  • Attachments are recorded by filename only. Customer.io documents a {filename: base64} map; our backend sends [{filename, content, content_type}]. Both are accepted; whether the real API accepts the array form is unverified (no sandbox credentials).
  • Response bodies of the CDP host ({success: true}) and exact App API error strings other than transactional_message_id not found are unverified.

API

Export Kind Description
CustomerIoAPI class The in-process mock: fetch(request), reset(), report(input), profiles(), state. Options: sqlite, now, namespace, messages, settings, onReport.
createRuntime function The mock with the full service contract (health, admin, namespaces, credentials, presets, outbox, reporting webhooks). Options: webhooks: {url, secret, retryDelaysMs?, fetch?}, messages, settings, clock, seed, adminKey, onLog, sqlite.
CUSTOMERIO_PRESETS object Every named fault preset.
CUSTOMERIO_NAMESPACE string The service name, "customerio".
REPORTING_WEBHOOK_PATH string Our receiver's path, /v1/customer-io/reporting-webhook.
signReporting function (secret, timestampSeconds, body) → the hex x-cio-signature.
customerIoCredential function The write key or App API key a request carries.
DEFAULT_TRANSACTIONAL_MESSAGES, DEFAULT_SETTINGS, TRANSACTIONAL_EMAIL_KEYS values The seeded catalog, settings, and our backend's legacy email keys.
document, operationIds, supportedOperationIds values The vendored OpenAPI contract and its operation ids.
createServer, serveTarget, DEFAULT_PORT (./server) Node Serve over node:http; the serve CLI target; port 8810.

Part of mockingbird.

to navigate to open