Owlmetry
API Reference

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"
}
FieldTypeRequiredDescription
anonymous_idstringYesThe anonymous ID to claim. Must start with owl_anon_.
user_idstringYesThe authenticated user's ID. Must not start with owl_anon_.

Validation rules

  • anonymous_id must start with the owl_anon_ prefix.
  • user_id must not start with owl_anon_.
  • The API key must be scoped to an app (client keys only).

Behavior

  1. All events rows where user_id matches anonymous_id across all apps in the project are updated to the new user_id.
  2. 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-deleted feedback and event_attachments rows are left untouched.
  3. questionnaire_responses rows for the anonymous user migrate to the real user_id per 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 NULL partial unique index).
  4. The app_users mapping is recorded:
    • If both an anonymous user record and the identified user record exist, the anonymous record is merged into the identified one (claimed_from appends the anonymous ID, first_seen_at takes 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_anonymous flips to false, and claimed_from is set to [anonymous_id].
    • If only the identified record exists, claimed_from appends 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].

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": ""
  }
}
FieldTypeRequiredDescription
user_idstringYesThe user to update. May be an anonymous ID (owl_anon_*) or a real user ID.
propertiesobjectYesKey-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

ParameterValuesDescription
sourceapple-search-adsAttribution 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>"
}
FieldTypeRequiredDescription
user_idstringYesThe user (anonymous or real) to attach attribution properties to.
attribution_tokenstringYesOpaque token from the ad network. For Apple Search Ads this is AAAttribution.attributionToken().
dev_mock"attributed" | "unattributed" | "pending"NoDevelopment 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 when attributed is true. Not written for apple_test_install responses: the IDs Apple returns in that case are placeholders, so storing them just pollutes dashboards and burns an Apple Ads enrichment call.

Ready to get started?

Connect your agent via MCP or CLI and start tracking.