Identity
Identity endpoints — claim anonymous events for a known user ID, set user properties, and submit attribution tokens.
Identity management: link anonymous events to a known user ID after authentication, attach key-value properties to users, and submit ad-network attribution tokens.
POST /v1/identity/claim
Reassign every row keyed on an anonymous user — events, metric events, funnel events, issue occurrences, feedback, event attachments, and questionnaire responses — to a known user ID, and merge the corresponding user records.
Auth required: Yes (client API key with events:write permission)
Request
{
"anonymous_id": "owl_anon_abc123xyz",
"user_id": "user-456"
}| Field | Type | Required | Description |
|---|---|---|---|
anonymous_id | string | Yes | The anonymous ID to claim. Must start with owl_anon_. |
user_id | string | Yes | The authenticated user's ID. Must not start with owl_anon_. |
Validation rules
anonymous_idmust start with theowl_anon_prefix.user_idmust not start withowl_anon_.- The API key must be scoped to an app (client keys only).
Behavior
- All
eventsrows whereuser_idmatchesanonymous_idacross all apps in the project are updated to the newuser_id. - The same reassignment runs on every other table that carries the SDK end-user id:
funnel_events,metric_events,issue_occurrences,feedback,event_attachments. Soft-deletedfeedbackandevent_attachmentsrows are left untouched. questionnaire_responsesrows for the anonymous user migrate to the realuser_idper slug, but only when the real user has no existing response for that slug — any conflicting anon draft is soft-deleted so the submitted real response wins (enforced by the(project_id, slug, user_id) WHERE deleted_at IS NULL AND user_id IS NOT NULLpartial unique index).- The
app_usersmapping is recorded:- If both an anonymous user record and the identified user record exist, the anonymous record is merged into the identified one (
claimed_fromappends the anonymous ID,first_seen_attakes the earlier of the two, junction rows merge) and the anonymous record is deleted. - If only the anonymous record exists, it is renamed in place to
user_id,is_anonymousflips tofalse, andclaimed_fromis set to[anonymous_id]. - If only the identified record exists,
claimed_fromappends the anonymous ID. - If neither exists (the SDK fired the claim before any event reached the server), a new identified record is inserted with
claimed_from: [anonymous_id].
- If both an anonymous user record and the identified user record exist, the anonymous record is merged into the identified one (
Idempotent
If the same anonymous_id has already been claimed by the same user_id, the endpoint returns success with events_reassigned_count: 0 without making any changes.
Response
Always returns 200 on a successful claim, even when zero events were reassigned (the SDK can race its own ingest flush — see "SDK integration note" below):
// Events were present and reassigned (200)
{
"claimed": true,
"events_reassigned_count": 42
}
// Already claimed, or claim arrived before any events (200)
{
"claimed": true,
"events_reassigned_count": 0
}SDK integration note
SDKs flush all buffered events before calling this endpoint so the server's UPDATE events reassignment catches them in one pass. Even if a claim races ahead of its own ingest flush (early in a session, when the buffer hasn't been drained yet), the server still registers the anon → real mapping in app_users.claimed_from. Late-arriving events are then rewritten by the ingest-side re-attribution below — no manual retry is needed.
Ingest-side re-attribution
Once an anonymous ID has been claimed, /v1/ingest also re-attributes any events that arrive tagged with that anon ID to the real user. This keeps offline-queued events flushed after the claim consistent with events sent before it — you do not need a second claim call to mop up stragglers.
POST /v1/identity/properties
Set, merge, or delete user properties for a known user. Properties are stored as a JSONB object on the app_users table and are project-scoped.
Auth required: Yes (client API key with users:write permission)
Request
{
"user_id": "user-456",
"properties": {
"plan": "pro",
"signup_source": "landing_page",
"removed_key": ""
}
}| Field | Type | Required | Description |
|---|---|---|---|
user_id | string | Yes | The user to update. May be an anonymous ID (owl_anon_*) or a real user ID. |
properties | object | Yes | Key-value string pairs to merge. Empty-string values delete the key. |
Validation rules
- Keys must be non-empty strings, max 50 characters.
- Values must be strings, max 200 characters.
- After merging, total property count cannot exceed 50.
- The API key must be scoped to an app (client keys only).
- Value of
""(empty string) deletes the key from the user's properties.
Response
// Success (200)
{
"updated": true,
"properties": {
"plan": "pro",
"signup_source": "landing_page"
}
}The returned properties object reflects the full post-merge state for the user. The merge operation is race-condition safe.
POST /v1/identity/attribution/:source
Submit an ad-network attribution token for server-side resolution. The server calls the network's attribution API (e.g. Apple) and writes the result to the user's properties via the same merge semantics as /v1/identity/properties.
Auth required: Yes (client API key with users:write permission, scoped to an app)
Path parameters
| Parameter | Values | Description |
|---|---|---|
source | apple-search-ads | Attribution network. The route is future-proofed — additional networks will slot in under new source values. |
Request
{
"user_id": "owl_anon_A1B2…",
"attribution_token": "<AdServices token>"
}| Field | Type | Required | Description |
|---|---|---|---|
user_id | string | Yes | The user (anonymous or real) to attach attribution properties to. |
attribution_token | string | Yes | Opaque token from the ad network. For Apple Search Ads this is AAAttribution.attributionToken(). |
dev_mock | "attributed" | "unattributed" | "pending" | No | Development helper — ignored when NODE_ENV=production. |
Response
// Attributed (200)
{
"attributed": true,
"pending": false,
"properties": {
"attribution_source": "apple_search_ads",
"asa_campaign_id": "123",
"asa_ad_group_id": "456",
"asa_keyword_id": "789",
"asa_claim_type": "click",
"asa_ad_id": "42",
"asa_creative_set_id": "7"
}
}
// Not attributed (200)
{ "attributed": false, "pending": false, "properties": { "attribution_source": "none" } }
// Apple test fixture — TestFlight, Xcode dev build, or simulator (200)
{ "attributed": false, "pending": false, "properties": { "attribution_source": "apple_test_install" } }
// Pending — Apple hasn't built the record yet (200)
{ "attributed": null, "pending": true, "retry_after_seconds": 3600, "properties": {} }On a pending response the caller should retry after retry_after_seconds. The Swift SDK retries across app launches up to ASA_MAX_PENDING_ATTEMPTS (5) times before writing attribution_source = "none" and stopping. Returns 400 for an invalid token, 502 for an upstream Apple error.
Properties written
The server writes via the same mergeUserProperties helper used by /v1/identity/properties, so these keys merge into the user's existing properties and survive identity claim:
attribution_source—"apple_search_ads"when Apple attributed,"none"when capture completed but nothing attributed, or"apple_test_install"when Apple returned its deliberate non-production fixture (TestFlight, Xcode-deployed dev build on a real device, or the iOS simulator). The fixture is detected by the structural tell of a single numeric ID repeated across campaign, ad group, and ad — something real Apple data can never produce.asa_campaign_id,asa_ad_group_id,asa_keyword_id,asa_claim_type,asa_ad_id,asa_creative_set_id— populated only whenattributedistrue. Not written forapple_test_installresponses: the IDs Apple returns in that case are placeholders, so storing them just pollutes dashboards and burns an Apple Ads enrichment call.
