Customer.io: Enrich user profiles with Sahha Archetypes
This guide shows how to synchronize Sahha Archetypes (weekly/monthly behavioral archetypes) into Customer.io user attributes, so you can build segments and campaigns based on behavior patterns.
You’ll implement:
- Real-time sync via Sahha Webhooks (
ArchetypeCreatedIntegrationEvent) - Optional backfill via the Sahha Archetypes API
- Optional event tracking in Customer.io to trigger journeys when archetypes change
What you’ll build
When Sahha assigns an archetype (e.g., sleep_duration = short_sleeper), your integration will:
- Verify the webhook signature (security)
- Map the archetype record into stable Customer.io attributes
- Update the corresponding Customer.io person via the Track API
Example resulting Customer.io attributes:
sahha_archetype_monthly_sleep_duration = "short_sleeper"sahha_archetype_monthly_sleep_duration_ordinality = 1sahha_archetype_monthly_sleep_duration_window_start_ts = 1735657200sahha_archetype_monthly_sleep_duration_window_end_ts = 1738252800sahha_archetype_last_updated_ts = 1738415333
Prerequisites
Sahha
- Sahha account
- A configured webhook endpoint (HTTPS)
- Your Sahha webhook secret key (for
X-Signatureverification)
Customer.io
- Customer.io Site ID and Track API Key (a “secret key”)
- You know whether your workspace identifies people by
idoremail
Important: The cleanest mapping is to use the same identifier for:
- Sahha
externalId- Customer.io
{identifier}(path parameter)This guide assumes
externalIdis the Customer.io identifier.
Step 1 — Determine your Customer.io Track API base URL (region-safe)
Customer.io accounts can live in different regions. Use the region lookup endpoint to get the correct base URL for subsequent Track API requests.
curl --request GET \
--user 'YOUR_SITE_ID:YOUR_API_SECRET_KEY' \
--url https://track.customer.io/api/v1/accounts/region
The response returns a url like https://track.customer.io (US) or another regional URL. Use that returned URL as your CIO_BASE_URL.
Set environment variables:
export CIO_SITE_ID="YOUR_SITE_ID"
export CIO_API_KEY="YOUR_API_SECRET_KEY"
export CIO_BASE_URL="https://track.customer.io" # replace with the returned 'url'
Step 2 — Decide how you’ll identify users (avoid “Attribute Update Failure”)
Customer.io’s “Add or update a customer” endpoint updates a person based on {identifier} in the URL. If you send conflicting identifiers in the request body, you can get “Attribute Update Failure” behavior (even if the API returns 200 OK).
Recommendation (safest):
- Put your identifier only in the URL path
- Do not include
idoremailin the body unless you explicitly need to set/update identifiers
We’ll update the user like this:
PUT /api/v1/customers/{externalId}- Body contains only attributes (+
_timestamp, optionally_update)
Step 3 — Configure Sahha Webhooks to send Archetypes
In the Sahha Dashboard:
- Go to Webhooks
- Add your endpoint URL (must be HTTPS)
- Select the event type:
ArchetypeCreatedIntegrationEvent - Copy your secret key (you’ll use it to verify
X-Signature)
Sahha sends these headers with every webhook:
X-Signature(HMAC-SHA256 of the raw payload)X-External-Id(the user identifier in your system)X-Event-Type(e.g.,ArchetypeCreatedIntegrationEvent)
Also note:
- Sahha retries failed deliveries (up to 5 times with backoff)
- Events may arrive out of order — use timestamps for sequencing
Step 4 — Map Sahha Archetypes into Customer.io attributes
Sahha archetype fields you’ll receive
An archetype assignment includes:
name(e.g.,sleep_duration)value(e.g.,short_sleeper)periodicity(e.g.,weekly,monthly)ordinality(integer)startDateTime,endDateTime(analysis window)createdAtUtc(when the archetype was created)id(UUID of the archetype record)
Attribute naming strategy (flat + predictable)
Customer.io “Attributes” are commonly used as primitives. Keep it flat:
sahha_archetype_${periodicity}_${name}sahha_archetype_${periodicity}_${name}_ordinalitysahha_archetype_${periodicity}_${name}_window_start_tssahha_archetype_${periodicity}_${name}_window_end_tssahha_archetype_${periodicity}_${name}_created_tssahha_archetype_last_updated_ts
This yields stable segments like:
- “monthly_sleep_duration is short_sleeper”
- “weekly_activity_level ordinality >= 2”
Step 5 — Implement the webhook receiver (Node.js / Express)
This example:
- Uses
express.text()so you can hash the raw body - Verifies
X-Signature - Deduplicates by Sahha
payload.id(recommended because Sahha retries) - Converts
createdAtUtcto a Unix timestamp - Updates Customer.io user attributes via Track API
Install
npm i express node-fetch
Server code
import express from "express";
import crypto from "crypto";
import fetch from "node-fetch";
const app = express();
// IMPORTANT: we need the raw request body for signature verification
app.use(express.text({ type: "*/*" }));
const SAHHA_WEBHOOK_SECRET = process.env.SAHHA_WEBHOOK_SECRET;
const CIO_SITE_ID = process.env.CIO_SITE_ID;
const CIO_API_KEY = process.env.CIO_API_KEY;
const CIO_BASE_URL = process.env.CIO_BASE_URL;
// A minimal in-memory dedupe store for tutorial purposes.
// In production, store this in Redis/Dynamo/Postgres with TTL.
const processed = new Set();
function unixSeconds(isoString) {
// createdAtUtc may include fractional seconds; Date handles it.
return Math.floor(new Date(isoString).getTime() / 1000);
}
function basicAuthHeader(user, pass) {
const token = Buffer.from(`${user}:${pass}`, "utf8").toString("base64");
return `Basic ${token}`;
}
async function updateCustomerIoAttributes(identifier, attrs, eventTs, { updateOnly = false } = {}) {
const url = `${CIO_BASE_URL}/api/v1/customers/${encodeURIComponent(identifier)}`;
const body = {
...attrs,
// Customer.io uses this to order attribute updates if multiple arrive quickly
_timestamp: eventTs,
// If you ONLY want to update existing profiles and never create new ones:
// set _update:true to prevent accidental new profile creation.
...(updateOnly ? { _update: true } : {}),
};
const res = await fetch(url, {
method: "PUT",
headers: {
Authorization: basicAuthHeader(CIO_SITE_ID, CIO_API_KEY),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Customer.io update failed (${res.status}): ${text}`);
}
}
async function trackCustomerIoEvent(identifier, name, data, { timestamp } = {}) {
const url = `${CIO_BASE_URL}/api/v1/customers/${encodeURIComponent(identifier)}/events`;
const body = {
name,
data,
...(timestamp ? { timestamp } : {}),
};
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: basicAuthHeader(CIO_SITE_ID, CIO_API_KEY),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Customer.io event failed (${res.status}): ${text}`);
}
}
app.post("/webhooks/sahha", async (req, res) => {
try {
const signature = req.get("X-Signature");
const externalId = req.get("X-External-Id");
const eventType = req.get("X-Event-Type");
if (!signature || !externalId || !eventType) {
return res.status(400).json({ error: "Missing required headers" });
}
// Verify Sahha signature: HMAC-SHA256 of raw body using the Sahha webhook secret
const computed = crypto
.createHmac("sha256", SAHHA_WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
if (signature.toLowerCase() !== computed.toLowerCase()) {
return res.status(401).json({ error: "Invalid signature" });
}
// Acknowledge receipt quickly (recommended), then do work async.
// For tutorial clarity, we'll do work inline but still respond fast.
const payload = JSON.parse(req.body);
// Only handle archetypes for this guide
if (eventType !== "ArchetypeCreatedIntegrationEvent") {
return res.status(200).json({ received: true, ignored: true });
}
// Deduplicate webhook retries using Sahha archetype record id
if (payload?.id && processed.has(payload.id)) {
return res.status(200).json({ received: true, duplicate: true });
}
if (payload?.id) processed.add(payload.id);
// Sahha Archetype payload fields
const periodicity = payload.periodicity; // weekly/monthly
const archetypeName = payload.name; // e.g., sleep_duration
const archetypeValue = payload.value; // e.g., short_sleeper
const ordinality = payload.ordinality ?? null; // integer
const startDateTime = payload.startDateTime;
const endDateTime = payload.endDateTime;
const createdAtUtc = payload.createdAtUtc;
// Use Sahha createdAtUtc to order updates (helps when events arrive out of order)
const eventTs = unixSeconds(createdAtUtc);
// Flatten attributes for Customer.io
const keyBase = `sahha_archetype_${periodicity}_${archetypeName}`;
const attrs = {
[keyBase]: archetypeValue,
[`${keyBase}_ordinality`]: ordinality ?? 0,
[`${keyBase}_window_start_ts`]: startDateTime ? unixSeconds(startDateTime) : eventTs,
[`${keyBase}_window_end_ts`]: endDateTime ? unixSeconds(endDateTime) : eventTs,
[`${keyBase}_created_ts`]: eventTs,
sahha_archetype_last_updated_ts: eventTs,
};
// Update Customer.io profile (externalId becomes the Track API identifier)
await updateCustomerIoAttributes(externalId, attrs, eventTs, {
updateOnly: false, // set true if you want to avoid creating profiles
});
// Optional: track an event for journey triggers
// (If you do this, you can trigger a journey on "sahha_archetype_assigned".)
await trackCustomerIoEvent(
externalId,
"sahha_archetype_assigned",
{
periodicity,
name: archetypeName,
value: archetypeValue,
ordinality: ordinality ?? 0,
},
{ timestamp: eventTs }
);
return res.status(200).json({ received: true });
} catch (err) {
// Returning non-2xx triggers Sahha retries; only do this for transient errors.
return res.status(500).json({ error: err.message || "Unhandled error" });
}
});
app.listen(3000, () => {
console.log("Listening on :3000");
});
Step 6 — Test end-to-end
1) Send a test webhook from Sahha
Use the Send Test Event button in the Sahha Dashboard for your webhook.
2) Validate in Customer.io
In Customer.io, check the user profile and confirm:
- The
sahha_archetype_*attributes exist - The values match the webhook payload
- If you enabled the optional event, confirm the user event appears
Step 7 — Build segments and journeys in Customer.io
Once attributes are available, you can build:
-
Segments that filter users by archetype attributes
Example:sahha_archetype_monthly_sleep_duration = short_sleeper -
Journeys/Campaigns triggered by the optional event
Example trigger: event namesahha_archetype_assigned
Then branch using conditions ondata.periodicity,data.name,data.value.
Optional: Backfill archetypes for existing users (Sahha API)
If you want users to have archetypes in Customer.io immediately (instead of waiting for the next weekly/monthly recomputation), you can backfill via Sahha’s Archetypes API.
1) Get an Account Token
curl --request POST \
--url https://api.sahha.ai/api/v1/oauth/account/token \
--header 'Content-Type: application/json' \
--data '{
"clientId": "YOUR_CLIENT_ID",
"clientSecret": "YOUR_CLIENT_SECRET"
}'
Response includes an accountToken.
2) Query archetypes for a profile (by externalId)
curl --request GET \
--url "https://api.sahha.ai/api/v1/profile/archetypes/YOUR_EXTERNAL_ID" \
--header "Authorization: account YOUR_ACCOUNT_TOKEN"
This returns archetype assignments with fields like:
name, value, ordinality, periodicity, startDateTime, endDateTime, createdAtUtc.
3) Write a backfill script
High-level steps:
- Iterate your user list (the same identifiers you use in Customer.io)
- For each user:
- Call Sahha archetypes endpoint
- For each returned archetype record:
- Apply the same attribute mapping as the webhook handler
- Update the user in Customer.io with
PUT /customers/{identifier}
Tip: For backfill, set Customer.io
_timestampto the archetype record’screatedAtUtc(converted to unix seconds). This keeps ordering stable if you later receive late webhooks.
Reliability + correctness checklist
Security
- Verify
X-Signatureusing the raw body and your Sahha secret key - Reject requests missing required headers
Idempotency
- Deduplicate webhook retries using Sahha payload
id
Ordering
- Use Sahha
createdAtUtcas Customer.io_timestampso newer archetypes win if deliveries arrive out of order
Avoid profile duplication
- If you are updating users that must already exist in Customer.io, set
_update: true - If you want Customer.io to create the user if missing, leave
_updateunset/false
Avoid identifier conflicts
- Put the identifier in the request path
- Avoid sending
id/emailin the body unless you explicitly need identifier management
Troubleshooting
“200 OK but I don’t see attributes updating”
Common causes:
- You updated the wrong region URL (run the region lookup again)
- Your
{identifier}doesn’t match the actual user identifier in Customer.io - You sent conflicting identifiers in the request body, causing “Attribute Update Failure” patterns
Sahha retries keep happening
- Ensure you return
2xxquickly - If Customer.io is down transiently, return
500so Sahha retries - Otherwise, handle errors internally and still return
200to avoid repeated replays
Summary
You now have a repeatable pattern to:
- Receive Sahha archetypes in real-time
- Store them as stable Customer.io attributes
- Segment and message users based on behavioral archetypes
- Optionally trigger journeys via a dedicated event
If you want to extend this pattern, the same approach applies to Sahha Scores and Biomarkers (attributes + optional trigger events).