Skip to content

Customer.io

Connect Customer.io to JustAI to run AI-optimized copy experiments in your existing campaigns. JustAI serves the best content variant to each recipient at send time and learns from opens, clicks, and conversions.

  1. A Webhook Action in your Customer.io campaign asks JustAI for content for each recipient.
  2. JustAI’s response is stored as an attribute on the customer profile or the journey run.
  3. Your message renders that content with Liquid, so each recipient sees the variant JustAI picked for them.
  4. Customer.io reporting events (sends, opens, clicks, conversions) flow back to JustAI, so it learns which variants win.
  • Confirm which Customer.io workspace (Production vs other) you’re connecting.
  • Your JustAI org slug — usually your company name in lowercase (ask us if you’re not sure).
  • Make sure you have permissions to create API credentials and webhooks in Customer.io.

This is one-time setup — all templates will share this configuration. Once it’s done, JustAI receives the reporting events it needs to measure performance (send/open/click/conversion) and power downstream workflows.

Create an App API key in your Customer.io workspace:

  • Navigate to Workspace settings → API and Webhook Credentials → App API Keys.
  • Suggested values:
    • Name: JustAI
    • Workspace: Production (or your chosen workspace)

Customer.io API Key Dashboard

Step 2 — Save the Customer.io key in JustAI

Section titled “Step 2 — Save the Customer.io key in JustAI”

In the JustAI console:

  1. Open Org Integration Settings.
  2. Select Customer.io as the ESP integration.
  3. Choose the appropriate workspace (for example, Production).
  4. Paste the Customer.io API key and save changes.

Console Customer.io API Settings

Generate a JustAI API key.

Store the key in a password manager. You’ll use this later for the Customer.io Webhook Action calls in your campaigns.

Create a Reporting Webhook in Customer.io so campaign events flow back to JustAI:

  1. Go to Data & Integrations → Integrations → Add Integration → Reporting Webhook.
  2. Fill out the form:
    • Webhook Name: JustAI
    • Endpoint: https://worker.justwords.ai/api/webhook/cio/<org_slug>
      • Replace <org_slug> with your org’s name.
    • Events: select the events you want JustAI to receive (start with send/open/click/conversion).
    • Options: Send only the first time the event occurs.
  3. Press Save and Enable Webhook.

CIO Webhook Settings

Follow these steps for each campaign you want to optimize with JustAI.

  1. In Customer.io, open the campaign workflow and set up your control/treatment split — control is your existing message, treatment is the JustAI-optimized version:
    • Control: existing message.
    • Treatment: a Webhook Action followed by the JustAI message.
  2. Configure the Webhook Action request:
    • Method: POST
    • URL: https://worker.justwords.ai/api/generate/<org_slug>
    • Headers:
      • X-Api-Key: <JUSTAI_API_KEY>
      • Content-Type: application/json
    • Body: use the payload recommended in JustAI (Template → Integration Settings → Customer.io).
  3. Configure the Response tab:
    • Attribute name: jw_<template_id>
    • Value: {{ response.copy | json }}
    • Source: choose customer attribute or journey trigger data depending on your attribute scope (see Attribute scope below).
  4. In JustAI, open the template Integration Settings and set:
    • Campaign ID
    • Webhook Action ID (and Control Action ID if you use multiple controls)
    • Attribute Scope (if enabled — see below)
  5. Save changes, then reference the stored data in your Customer.io message using the appropriate Liquid prefix:
    • Customer attributes: {{ customer.jw_<template_id>.vars.<var_name> }}
    • Journey attributes: {{ journey.jw_<template_id>.vars.<var_name> }}

For email templates with an HTML body variable, the body is the exception — JustAI writes it into the Customer.io email for you rather than returning it in the webhook response. See Large HTML emails.

When the JustAI webhook returns a response, Customer.io stores it as an attribute that you reference in your message Liquid. There are two places this data can live:

ScopeLiquid prefixStored onBest for
Customer attributescustomer.jw_*The customer profileSimple setups, data needed across multiple journeys
Journey attributesjourney.jw_*The current journey runMost setups — avoids bloating the customer profile with per-template data

Journey attributes are scoped to a single journey execution and are automatically cleaned up. Customer attributes persist on the profile indefinitely. For most use cases, journey attributes are recommended to keep customer profiles lean.

For more details, see the Customer.io docs on journey attributes and the journey attributes release notes.

Per account (default): In JustAI, go to Org Integration Settings and set the Default Attribute Scope on your Customer.io workspace. All new templates will inherit this default.

Per template (override): In the template’s Integration Settings, the Attribute Scope selector lets you override the account default. This is useful when migrating — existing templates can stay on customer while new ones use journey.

The webhook response configuration in Customer.io differs slightly depending on the scope:

  • Customer attributes: In the Response tab, click “Set up an attribute”, choose the attribute name, and enter jw_<template_id>.
  • Journey attributes: In the Response tab, click “Set up an attribute”, choose journey trigger data as the source, and enter jw_<template_id>.

Customer.io limits how much data it will store from a webhook response (the cap was 10KB historically and is now 100KB). A full HTML email body routinely exceeds that, so JustAI does not send the HTML back in the webhook response for email templates.

Instead, JustAI writes the HTML into the Customer.io email itself and returns only the ID of the variant it picked, so the email body no longer counts toward the response size cap. Everything else you send back still does — another large HTML or json variable, or "trace": true left in the webhook body, can still push the response over the limit. See Troubleshooting if you hit it.

  1. The webhook response stays small. JustAI drops the body variable from the response. What comes back is copy.id — the ID of the variant chosen for this recipient — plus your small variables (subject, preheader, and so on).
  2. JustAI syncs the HTML into Customer.io. Whenever you save, roll out, or ship the template, JustAI updates the Customer.io email over the API so its body is a Liquid conditional covering every active and control variant.
  3. Customer.io renders the matching branch at send time. The conditional is keyed on the variant ID stored by the webhook, so each recipient gets the variant JustAI selected.

The synced body looks like this (using customer. or journey. to match your attribute scope):

{% if customer.jw_123.id == "8f2c…" %}Ready to upgrade?
{% elsif customer.jw_123.id == "b41a…" %}Your upgrade is waiting
{% else %}Upgrade today
{% endif %}

Only the parts that actually differ between variants get wrapped in a conditional. JustAI diffs the variant bodies, keeps the shared markup once, and minifies whitespace — so the email doesn’t grow by a full copy for every variant you add.

The most-default variant becomes the plain {% else %} branch. If the webhook fails or no variant ID is stored, Customer.io still renders that branch rather than sending a blank email.

subject and preheader are small, so they keep coming through the response — with a hard-coded fallback in case one is missing:

{% if customer.jw_123.vars.subject %}{{ customer.jw_123.vars.subject }}{% else %}Upgrade today{% endif %}

Only the variable named body is handled this way. Other HTML variables still come back in the response and render with the raw filter:

{% if customer.jw_123.vars.hero %}{{ customer.jw_123.vars.hero | raw }}{% else %}<h1>Upgrade today</h1>{% endif %}

Keep those lean — they still count toward the response size cap.

JustAI omits body from the webhook response when the first two conditions below are true. To write that body into Customer.io instead, the integration must satisfy all three:

  • The template type is email.
  • The template has a variable named body with type HTML.
  • The template’s Integration Settings have either a Treatment Action ID or a Design Studio Email ID set.

If the treatment email was built in Customer.io’s Design Studio, turn on Enable Design Studio in Integration Settings and select the email — Design Studio emails can’t be updated through the campaigns API, so JustAI needs the email ID to sync.

The default webhook body works for basic setups. For personalization, pass additional user attributes to JustAI using the attrs field in the request body. JustAI can auto-sync these attributes to keep your personalization data up to date.

Add an attrs object to your webhook body:

{
"attrs": {
"<attribute_name>": "{{ customer.<field_name> }}"
}
}

For example, to pass a user’s persona:

{
"attrs": {
"persona": "{{ customer.persona }}"
}
}

Pass multiple attributes in a single request:

{
"attrs": {
"persona": "{{ customer.persona }}",
"plan": "{{ customer.plan_type }}",
"city": "{{ customer.city }}"
}
}

Use Liquid conditionals to provide default values when attributes might be missing:

{% if customer.plan_type %}{{ customer.plan_type }}{% else %}free{% endif %}

Anything referenceable in Customer.io Liquid is available. You can reference nested objects, arrays, and relationships:

  • {{ customer.company.name }} - nested object property.
  • {{ customer.tags | first }} - first item in an array.
  • {{ customer.subscription.status }} - related object property.

For event-triggered campaigns, event data is accessible via {{ event.<field> }}:

{
"attrs": {
"product_category": "{{ event.product_category }}",
"order_value": "{{ event.order_value }}"
}
}

A complete webhook body with required fields and custom attributes:

{
"template_id": "<template_id>",
"user_id": "{{ customer.id }}",
"tracking_id": "{{ message.journey_id }}",
"attrs": {
"persona": "{{ customer.persona }}",
"plan": "{% if customer.plan_type %}{{ customer.plan_type }}{% else %}free{% endif %}",
"company_name": "{{ customer.company.name }}"
}
}

JustAI supports two ways to pass user data: attrs and fields.

ParameterPurposeUse When
attrsAttributes used for ranking and filtering variantsYou want JustAI to select different content based on user segments (e.g., persona, plan type).
fieldsPersonalization fields returned as hydrated stringsYou need to insert user-specific values (e.g., first name) into the generated content without affecting variant selection.

Use fields for simple personalization like names or account details:

{
"fields": {
"first_name": "{{ customer.first_name }}",
"account_number": "{{ customer.account_id }}"
}
}

You can combine both in the same request:

{
"attrs": {
"persona": "{{ customer.persona }}",
"plan": "{{ customer.plan_type }}"
},
"fields": {
"first_name": "{{ customer.first_name }}"
}
}

In this example, JustAI uses persona and plan to select the best variant, then hydrates first_name into the returned content.

For more details, see the Customer.io docs on Webhook Actions and Liquid personalization.

Customer.io conversion metrics are tied to a campaign. If you need additional metrics, you can send custom events to JustAI for correlation with message sends within a time window.

Options:

  • Daily batch ingestion via S3 (write data to a shared S3 bucket).
  • Streaming ingestion via a JustAI events webhook API (similar shape to the Customer.io webhook endpoint above).

Please reach out to our team if you need to add custom events and we can help you integrate them.

JustAI can export a record of which content each user received in your JustAI-integrated Customer.io campaigns, so your data team can analyze results in your own warehouse. The easiest strategy is a daily data export to a shared S3 bucket on your AWS account.

Each record reflects one JustAI API call, with a user ID and a tracking ID that uniquely identifies an email/notification in Customer.io.

{
"event_timestamp": <unix_timestamp>,
"user_id": <string>, // Customer.io
"tracking_id": <string>, // Customer.io
"copy_id": <uuid_string>, // JustAI
"template_id": <string>, // JustAI
// Record of strings, but depends on the template
"vars": {
"subject": <string>,
"preheader": <string>,
"body": <string>
},
// Record of strings, but depends on the template
"attrs": {
"persona": <string>,
"age": <string>
}
}

In JustAI, each variant has a UUID (copy_id) and a template ID. A template ID corresponds 1:1 with an email / push / etc within a Customer.io campaign, but there can be many variants per template.

The journey ID & action ID uniquely identifies an instance of an email / push / etc and is generated by Customer.io. In our dashboards, we’ll be aggregating the engagement metrics produced by Customer.io but grouped by copy_id and date to see the performance of each variant over time.

This is just a default, and there may be other preferred approaches (direct to Snowflake, etc).

  1. JustAI to provision an ARN role that will read/write to the shared AWS bucket.
  2. Client to create bucket or path in existing bucket and grant read/write access to the role (1)
  3. JustAI to export a backfill of data & to set up a daily export for new records.
  4. Client to transfer data from S3 into Snowflake (for example).

This is just a default, and there may be other preferred approaches (Avro, etc.).

  • The exported data to be in Parquet and written to a partitioned path like ”…/YYYY/MM/DD/HH”
  • Backfills to be run adhoc & would overwrite any existing data.
  • The copy variables can be modified in the frontend, so the UUID => vars could be different. It’s generally the case that we will not modify them once they are being served unless there is a typo / etc.
  • The copy metadata could be set up as a separate table rather than flattening them if that is easier for downstream analysis / better storage.
  • Retention can be handled as a bucket policy.
  • Webhook never fires — confirm the Reporting Webhook is enabled in Customer.io and the endpoint is correct (including <org_slug>).
  • No events arriving — confirm you selected the relevant events and that you are sending emails/triggering events in the correct workspace.
  • Credentials issues — re-check which Customer.io workspace you created the API key in, and that the same workspace is selected in JustAI.
  • {{ response.copy | json }} renders blank — confirm your workspace is on the upgraded Customer.io Liquid version. Older Liquid versions silently ignore the json filter.
  • Message renders blank values — the attribute scope in JustAI must match how you configured the Response tab in Customer.io. If JustAI generates journey.jw_* Liquid but you stored the response as a customer attribute (or vice versa), the values come back blank.
  • Webhook response rejected as too large — you shouldn’t hit this on email templates: JustAI omits the HTML body from the response and syncs it into the Customer.io email instead (see Large HTML emails). If it still happens, the usual causes are another oversized variable (a large HTML or json variable) or "trace": true left in the webhook body, which adds debug data to the response. Remove trace from production webhook calls.
  • Email body reverted after I edited it in Customer.io — expected. JustAI overwrites the managed email on every save/rollout/ship. Make the change in your JustAI variants.
  • Body doesn’t sync to Customer.io — confirm the body variable’s type is HTML and that Integration Settings has a Treatment Action ID or Design Studio Email ID. Also check the treatment action is an email action; JustAI skips other action types.