Send Sahha Archetypes to OneSignal User Tags
This guide shows how to enrich OneSignal user profiles with Sahha Archetypes so you can segment and personalize CX campaigns using OneSignal Data Tags.
You will:
- Configure Sahha to emit
ArchetypeCreatedIntegrationEventwebhooks. - Receive and verify webhook requests in your backend.
- Update OneSignal user tags via the OneSignal Update user API.
- Use tags in OneSignal Segments / Journeys.
Why this pattern exists
- Sahha computes long-term behavioral archetypes (e.g., chronotype, sleep pattern) over time windows (weekly/monthly, etc.).
- OneSignal tags are stored on the User, and are the most direct mechanism for segmentation and personalization.
Your backend bridges the two systems so you can keep credentials secure and enforce business rules (whitelisting, tag budgets, dedupe).
What you’re building (high level)
Sahha Webhook (ArchetypeCreatedIntegrationEvent) Your backend (verify signature + map identity + transform) OneSignal Update User (PATCH /apps/APP_ID/users/by/external_id/EXTERNAL_ID) Tags available for Segments / Journeys / Message personalization
Prerequisites
1) Use the same user identifier in both systems
This integration depends on a consistent identity:
- In Sahha webhooks, the user identity is
externalId(also sent via theX-External-Idheader). - In OneSignal, the most common alias is
external_id.
Recommended approach
Use your internal user ID as:
- Sahha
externalId - OneSignal
external_id
If these aren’t aligned, you will enrich the wrong OneSignal user (or no user at all).
2) Ensure OneSignal users are identified (external_id exists)
Users often start “anonymous” in OneSignal until you assign an external ID.
In your client app, identify the user early (example shown conceptually; follow the SDK docs for your platform):
// Example concept: identify user so OneSignal associates subscriptions to external_id
OneSignal.login("user_12345");
If you don’t identify users, OneSignal may not have a user record addressable by external_id.
3) Configure the Sahha webhook
In the Sahha Dashboard:
- Add your webhook endpoint URL (HTTPS required)
- Select event type:
ArchetypeCreatedIntegrationEvent - Copy the webhook secret key (used for signature verification)
- Use Send Test Event to validate your endpoint
Sahha sends these headers (used in verification + routing):
X-SignatureX-External-IdX-Event-Type
4) OneSignal credentials (server-side only)
You need:
- OneSignal
app_id - OneSignal App API Key (used with
Authorization: Key YOUR_APP_API_KEY)
Never ship OneSignal API keys to client apps.
OneSignal tag rules you must follow
Tag values must be strings
Even numbers/timestamps/booleans must be stringified.
Examples:
"42"not42"1685400000"for unix timestamps (seconds)"true"/"false"or"1"/"0"
Avoid restricted tag keys
OneSignal reserves certain keywords for internal message personalization.
Avoid common internal words like message, user, template, etc. (see OneSignal docs).
Recommendation: prefix every key with sahha_ or sahha_archetype_.
Plan limits: tags per user
OneSignal limits how many tags can exist on a user at one time depending on plan. If a user is at the limit, you may need to delete tags first before adding new ones.
Recommendation: Keep your Sahha tag footprint small and intentional.
Suggested tag mapping strategy
The Sahha archetype webhook payload includes at least:
name(category)value(assigned label/value)periodicity(daily/weekly/monthly)startDateTime/endDateTime(analysis window)createdAtUtc(assignment time)
A practical OneSignal mapping:
| Sahha Field | OneSignal Tag Key | OneSignal Tag Value |
|---|---|---|
name + value | sahha_archetype_${name} | ${value} |
periodicity | sahha_archetype_${name}_periodicity | ${periodicity} |
endDateTime | sahha_archetype_${name}_window_end | unix seconds string |
createdAtUtc | sahha_archetypes_updated_at | unix seconds string |
Keep it minimal: In many cases, you only need sahha_archetype_${name}.
Implementation: webhook receiver → OneSignal update
Below is a production-friendly Node/Express implementation that:
- reads the raw body (required for signature verification)
- verifies Sahha HMAC signature
- only processes the archetype event
- updates the OneSignal user by
external_id - stringifies all tag values
- enforces an allowlist (to protect your OneSignal tag budget)
Environment variables
# Sahha
SAHHA_WEBHOOK_SECRET="your_sahha_webhook_secret"
# OneSignal
ONESIGNAL_APP_ID="your_onesignal_app_id"
ONESIGNAL_APP_API_KEY="your_onesignal_app_api_key" # used in: Authorization: Key <...>
Server code (Express)
import express from "express";
import crypto from "crypto";
type SahhaArchetypeEvent = {
id: string;
profileId: string;
accountId: string;
externalId: string;
name: string; // e.g. "chronotype"
value: string; // e.g. "early_bird"
dataType: string;
periodicity: "daily" | "weekly" | "monthly";
ordinality: number;
startDateTime: string;
endDateTime: string;
createdAtUtc: string;
version: number;
};
const app = express();
/**
* IMPORTANT:
* Use raw text body so the HMAC signature is computed over the exact payload.
*/
app.use("/webhooks/sahha", express.text({ type: "*/*" }));
function timingSafeEqualString(a: string, b: string) {
const bufA = Buffer.from(a, "utf8");
const bufB = Buffer.from(b, "utf8");
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
function verifySahhaSignature(rawBody: string, signatureHeader: string, secret: string) {
const computed = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return timingSafeEqualString(signatureHeader.toLowerCase(), computed.toLowerCase());
}
/**
* Keep your OneSignal tag budget under control by only syncing archetypes you will actually use.
* Start small; expand when CX teams demonstrate value.
*/
const ALLOWED_ARCHETYPES = new Set<string>([
"chronotype",
"sleep_pattern",
"sleep_quality",
"activity_level",
"primary_exercise_type",
]);
function toUnixSeconds(iso: string): string {
const ms = Date.parse(iso);
if (Number.isNaN(ms)) return "";
return String(Math.floor(ms / 1000));
}
function buildOneSignalTags(payload: SahhaArchetypeEvent): Record<string, string> {
const tags: Record<string, string> = {};
// Required: archetype itself
tags[`sahha_archetype_${payload.name}`] = String(payload.value);
// Optional metadata (comment out if you want to keep tags minimal)
tags[`sahha_archetype_${payload.name}_periodicity`] = String(payload.periodicity);
tags[`sahha_archetype_${payload.name}_window_end`] = toUnixSeconds(payload.endDateTime) || "";
// Global timestamp for "last time Sahha tags were updated"
tags["sahha_archetypes_updated_at"] = toUnixSeconds(payload.createdAtUtc) || "";
// Remove empties (OneSignal expects strings, but empty values are usually not helpful)
for (const [k, v] of Object.entries(tags)) {
if (!v) delete tags[k];
}
return tags;
}
async function updateOneSignalUserTags(externalId: string, tags: Record<string, string>) {
const appId = process.env.ONESIGNAL_APP_ID!;
const apiKey = process.env.ONESIGNAL_APP_API_KEY!;
const url = `https://api.onesignal.com/apps/${encodeURIComponent(appId)}/users/by/external_id/${encodeURIComponent(externalId)}`;
const res = await fetch(url, {
method: "PATCH",
headers: {
"Authorization": `Key ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
properties: {
tags,
},
}),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
const err = new Error(`OneSignal update failed: ${res.status} ${res.statusText} ${body}`);
(err as any).status = res.status;
throw err;
}
}
app.post("/webhooks/sahha", async (req, res) => {
const rawBody = req.body as string;
const signature = req.get("X-Signature") || "";
const externalIdHeader = req.get("X-External-Id") || "";
const eventType = req.get("X-Event-Type") || "";
// Fast validation
if (!signature || !externalIdHeader || !eventType) {
return res.status(400).json({ error: "Missing required headers" });
}
// Verify signature
const secret = process.env.SAHHA_WEBHOOK_SECRET!;
const isValid = verifySahhaSignature(rawBody, signature, secret);
if (!isValid) {
return res.status(401).json({ error: "Invalid signature" });
}
// Acknowledge quickly (recommended). Do heavier work async if needed.
res.status(200).json({ received: true });
// Only handle archetype events here
if (eventType !== "ArchetypeCreatedIntegrationEvent") return;
let payload: SahhaArchetypeEvent;
try {
payload = JSON.parse(rawBody);
} catch {
console.error("Invalid JSON payload");
return;
}
// Trust but verify: header externalId should match payload externalId
const externalId = payload.externalId || externalIdHeader;
if (!externalId) return;
// Enforce allowlist to protect OneSignal tag limits
if (!ALLOWED_ARCHETYPES.has(payload.name)) return;
const tags = buildOneSignalTags(payload);
try {
await updateOneSignalUserTags(externalId, tags);
} catch (e) {
console.error("Failed to update OneSignal user tags", e);
}
});
app.listen(3000, () => {
console.log("Listening on :3000");
});
Testing & validation checklist
1) Validate the webhook endpoint (Sahha)
- Use Send Test Event in Sahha webhook settings.
- Confirm your server returns HTTP 200 quickly.
- Confirm logs show
eventType === ArchetypeCreatedIntegrationEvent.
2) Validate tag updates (OneSignal)
In OneSignal Dashboard:
- Go to Audience → Users
- Find a user by
external_id - Check Tags
- Confirm keys like:
sahha_archetype_chronotype = early_bird
3) Validate segmentation
Create a Segment:
- Condition:
sahha_archetype_chronotypeequalsearly_bird - Verify expected users appear.
4) Validate a campaign workflow
Create a Journey or Message:
- Target your Segment
- Use tag-driven personalization (optional)
- Confirm delivery behavior aligns with your channel subscriptions.
Operational guidance
Keep tag footprint small
Because OneSignal enforces tag limits per user and blocks new tags once the limit is reached, start with:
- 3–6 archetype tags that map directly to segmentation hypotheses
- One global update timestamp tag
Prefer updating existing keys over adding new ones
If you reuse the same key (e.g., sahha_archetype_chronotype) you’re updating an existing tag, which is less likely to be blocked by tag limits than adding new keys.
Don’t store full profiles in tags
OneSignal tags are for segmentation and personalization, not analytics logs. Keep durable profile history in your database.
Next steps (common extensions)
- Add a backfill job to sync the latest archetypes for existing users (use Sahha Archetypes API / SDK).
- Add support for additional Sahha event types (Scores, Biomarkers) using separate allowlists and distinct tag namespaces.
- Route webhook processing through a queue (SQS/PubSub) if you expect high volume.