Guides
Building a Unified Profile

Building a Unified Profile

A unified profile is a single record that consolidates everything Experiture knows about one person — regardless of which system, device, or identifier originally captured it. This guide explains how profile data accumulates, how identity resolution merges records across identifiers, and what operational patterns keep profiles clean.


What a profile is

A profile is a row in the profiles object. Every workspace has a profiles object by default. Fields vary by workspace configuration, but the identity-resolution layer tracks which identifiers have been observed for each profile.

Profiles accumulate data from multiple sources over time:

Signup form         → email, first_name, signup_source
Stripe webhook      → subscription_status, plan, subscription_updated_at
Shopify webhook     → ltv, order_count, last_order_at
Mobile SDK          → device_id, push_token, last_seen_at
CRM sync            → account_owner, industry, company_size

Each source writes independently. The CDP merges all of them into the same row when they share a common identifier.


The merge model

When you write a profile record, the API resolves which row to merge into using the matchKey you specify. If no matching row exists, a new one is inserted.

Write 1:  { email: "jane@co.com", tier: "gold" }           → creates row #1
Write 2:  { email: "jane@co.com", first_name: "Jane" }     → merges into row #1
Write 3:  { email: "jane@co.com", customer_id: "cust_99" } → row #1 now has both identifiers
Write 4:  { customer_id: "cust_99", last_order_at: "..." } → resolves to row #1 via customer_id

This is last-write-wins at the field level: each write updates only the fields it sends. Omitted fields are preserved.


Writing profile data

Use the /records/profiles/upsert endpoint for any profile update. Always specify a matchKey that matches an identity field in your workspace schema.

curl -X POST https://api.experiture.ai/public/v1/records/profiles/upsert \
  -H "Authorization: Bearer <your_access_token>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "record": {
      "email": "jane@example.com",
      "first_name": "Jane",
      "last_name": "Doe",
      "signup_source": "organic",
      "signed_up_at": "2026-04-21T15:30:00Z"
    },
    "matchKey": "email"
  }'

For a full breakdown of the upsert operation including field semantics and idempotency, see Real-time Record Writes.


Adding identifiers over time

The most powerful pattern is progressive profile enrichment: each touchpoint adds more identifiers, and the CDP links them all to the same underlying record.

Pattern: link email → customer_id at login

When a previously anonymous user authenticates, you know their email. Write both:

import requests
 
def upsert_profile(api_key: str, record: dict, match_key: str, idempotency_key: str):
    requests.post(
        "https://api.experiture.ai/public/v1/records/profiles/upsert",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
            "Idempotency-Key": idempotency_key,
        },
        json={"record": record, "matchKey": match_key},
    ).raise_for_status()
 
# At login — link anonymous session to authenticated user
upsert_profile(
    api_key,
    record={
        "email": event["email"],
        "customer_id": event["customer_id"],
        "last_login_at": event["timestamp"],
    },
    match_key="email",
    idempotency_key=event["session_id"] + ":login",
)

After this write, subsequent upserts using either email or customer_id will resolve to the same row.

Pattern: link email → phone at checkout

upsert_profile(
    api_key,
    record={
        "email": order["customer_email"],
        "phone": normalize_phone(order["billing_phone"]),  # E.164 format
        "ltv": order["total_price"],
    },
    match_key="email",
    idempotency_key=f"order:{order['id']}:profile",
)

Always normalize phone numbers to E.164 (+12125551234) before writing. Inconsistent formatting creates duplicate profiles.


Multiple sources writing to the same profile

When several systems write to the same profile independently, think of each as owning a subset of fields:

# Stripe webhook — owns subscription fields
upsert_profile(
    api_key,
    record={
        "email": customer["email"],
        "subscription_status": sub["status"],
        "subscription_plan": sub["plan"]["id"],
        "subscription_updated_at": sub["current_period_end"],
    },
    match_key="email",
    idempotency_key=f"stripe:{event['id']}",
)
 
# Shopify webhook — owns commerce fields
upsert_profile(
    api_key,
    record={
        "email": order["email"],
        "order_count": order["customer"]["orders_count"],
        "ltv": float(order["customer"]["total_spent"]),
        "last_order_at": order["created_at"],
    },
    match_key="email",
    idempotency_key=f"shopify:{order['id']}:profile",
)

Each write only touches the fields it owns. The Stripe handler never sends order_count; the Shopify handler never sends subscription_status. This prevents later writes from accidentally nulling fields owned by other systems.


Avoiding common profile quality issues

Duplicate profiles

Duplicates happen when the same person appears under two different identifiers and no write ever links them. The most common causes:

  1. Email capture in different casing — always lowercase email before writing
  2. Phone number format inconsistency — normalize to E.164
  3. Anonymous sessions never linked — if your frontend captures events before login, link the anonymous session ID to the authenticated identity at login time
def normalize_email(email: str) -> str:
    return email.strip().lower()
 
def normalize_phone(phone: str) -> str:
    # Use phonenumbers library or a normalization service
    import phonenumbers
    parsed = phonenumbers.parse(phone, "US")
    return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)

Stale field values

Last-write-wins works correctly only when your writes are ordered. If an older event arrives out of order and overwrites a newer value, the profile is stale.

Guard time-sensitive fields with a timestamp check in your application logic:

def should_update_tier(profile_last_updated: str, event_timestamp: str) -> bool:
    from datetime import datetime, timezone
    last = datetime.fromisoformat(profile_last_updated)
    event = datetime.fromisoformat(event_timestamp)
    return event >= last

Or use a tier_updated_at timestamp field alongside tier — your campaign rules can filter on recency.

Null values clearing real data

Sending "field": null clears the field in the CDP. Sending null for first_name intentionally deletes it. If you're assembling records from upstream data and a field might be missing, use if field is not None filtering:

record = {"email": event["email"]}
 
# Only include fields that are actually present and non-null
for field in ("first_name", "last_name", "phone", "company"):
    value = event.get(field)
    if value is not None:
        record[field] = value

Querying profiles to verify enrichment

Use the Metadata API to understand what's in the schema, then verify a specific profile write landed correctly via the console:

# What fields does the profiles object have?
curl https://api.experiture.ai/public/v1/metadata/objects/profiles \
  -H "Authorization: Bearer <your_access_token>" \
  | jq '[.data.fields[] | {name, type, required}]'

In the Experiture console: Profiles → Search → enter the email or customer_id. You'll see all fields as they currently stand in the CDP.


High-volume profile enrichment

For bulk historical backfills (migrating from a CRM, enriching from a data warehouse), use Bulk File Import rather than individual upserts. Import jobs handle schema validation in bulk and are far more efficient for > 10,000 records.

For continuous streaming enrichment (e.g. real-time data platform events), use the Batch Record Writes buffer pattern to accumulate records and flush every 5 seconds or 500 records, whichever comes first.


See Also