> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Identity graph and device graph

> The two CDP graph views: a by-user identity graph that shows the identifiers stitched into a resolved profile with merge provenance, and a by-anonymous device graph that surfaces shared device/cookie signals across pre-identify sessions. Plus the identity-rules editor both views depend on.

# Identity graph and device graph

Three CDP tabs on **Integrations → CDP** answer the two questions an
operator asks most often about resolution — "which identifiers ended up
on this resolved profile?" and "which anonymous sessions are the same
not-yet-identified visitor?". The **Identity graph** tab answers the
first, the **Device graph** tab answers the second, and the **Identity
rules** tab authors the match contract both query through. This page
defines what each graph returns, how reads route between them, and the
privacy posture the API enforces. The resolution pipeline that feeds
both views is
[Identity resolution and merge semantics](/concepts/cdp-identity-resolution).

## When to read by-user versus by-anonymous

Every lookup into a graph starts from exactly one anchor; pick the
anchor by what you hold, not by what you want to see:

* **Anchor on `user_id`** for anything post-resolve. A resolved contact
  — an `identify` call paired an `anonymousId` with an `external_id`,
  email, or enabled phone rule — answers through the identity graph:
  which identifiers stitched onto the survivor, and which contacts
  merged in, with the fold decision attached.
* **Anchor on `anonymous_id`** for anything pre-resolve. Until an
  `identify` or `alias` fires, the only signal is the platform-minted
  `anonymousId`; a visitor who browses on a phone, then a laptop, then
  signs up on a third device lives as three anonymous sessions until one
  of them identifies. The device graph crosses those sessions on the
  shared device/cookie hints alone — no match rules invoked.

The failure shape to catch: reading by-user on a session that has not
identified returns a 404 comprehension error (`PROFILE_NOT_FOUND`), and
reading by-anonymous on a session that has identified wraps the
reachable-but-thin part of the cross-session overlay. Check which time
slice of the visitor journey the session came from before you pick a
tab.

## Section 1 — The by-user identity graph (post-resolve)

`GET /api/v1/cdp/profiles/by-user-id/:user_id/identity-graph` renders one
resolved profile as a node/edge graph. The `user_id` path param resolves
through the tenant's identity-rules chain (default: `external_id` only;
phone and email resolve only when the tenant has enabled those rules)
— and the dashboard tab also accepts a raw `cnt_`-prefixed contact id,
so a contact-detail page can hand its own id over directly. Nothing
matching returns a 404 with `PROFILE_NOT_FOUND`.

Three node/edge kinds carry the answer:

* **Identifier nodes** — `external_id`, `email`, `phone`, and the
  platform-stashed `anonymous_id` attached by `has_identifier` edges.
* **Contact nodes** — the surviving root contact plus every contact
  folded into it, connected by `merge` edges that carry the fold's
  decision metadata (action, actor, reason, timestamp).
* **Provenance** — a chronological, oldest-first timeline of every
  identity decision the audit ledger recorded for this profile,
  rendered beside the graph so the merge payload stays readable even
  when the picture is dense.

The `max_events` query caps how many audit rows the graph reads (1–1000,
default 200); a profile with a deep merge history sets `truncated: true`
when the tail was dropped, so a partial picture never reads as a
complete one. Merged-in contacts that transit an erasure or teardown
between resolve and render re-fail as not-found rather than returning
half a graph.

The graph is read-only. Splitting a bad fold back out goes through the
dashboard merge-history flow or the contacts unmerge surface (see
[Identity resolution](/concepts/cdp-identity-resolution) — the identity
graph never detaches identifiers itself.

## Section 2 — The by-anonymous device graph (pre-resolve)

`GET /api/v1/cdp/profiles/by-anonymous-id/:anonymous_id/device-graph`
renders the cross-device overlay one anonymous session participates in.
Client SDKs stamp `context.device_id` (IDFV/GAID-class hint) and/or
`context.cookie_id` (a first-party cookie that survives an `anonymousId`
reset) on each event; the graph reads those hints off the event stream
and expands the seed session in two bounded hops:

* **Hop 0 — the seed's own signals.** Every event the queried
  `anonymous_id` fired carrying a device or cookie hint.
* **Hop 1 — sessions sharing those values.** Every *other* anonymous
  session whose events carry one of the same values, bounded by the
  `window_days` lookback (1–90, default 30) so the unindexed context
  fan-out stays a bounded scan.

Edges classify as `seen_on_device` / `seen_with_cookie` (an anonymous
session fired a hint), or `identified_as` (that session later paired
with a `userId` — the strongest cross-device confirmation a pre-resolve
overlay can carry). The response lists every shared-device session in
`linked_anonymous_ids` with device/cookie/linked counts in `stats`, and
flags `truncated` when either hop's bound dropped a tail.

The graph is read-only: seeing two anonymous sessions on one device
does not stitch them — that stays an explicit `identify` or `alias`
call, two-hop only, so A→B→C transit walks stop at the first shared
hint. Fix the anchor on the seed session's pre-identify `anonymousId`,
not on a later user id.

## Section 3 — The identity-rules editor

`GET` / `PUT /api/v1/cdp/identity-rules` authors the match contract every
by-user graph read and every ingest lookup resolves through: one rule
row per identifier type (`external_id`, `email`, `phone`,
`anonymous_id`) with a numeric priority, enabled per type. The
dashboard tab renders and edits the same rows; the default chain is
`external_id`-only — phone and email rules resolve nothing until a
tenant enables them, so a shared-handset phone match cannot sneak into
a profile you did not opt in to match on. The
[Identity resolution](/concepts/cdp-identity-resolution) page specifies
how the simulator gates a rule change before the PUT saves it; a graph
read always uses the strongest enabled rule at query time, so a rule
change applies to the next lookup, not retroactively.

## Section 4 — How merge candidates propagate

Merge candidates flow from `identify`, imports, and the
resolution-engine scans into the contacts merge surface; the merge
writes one audit-ledger row (the fold decision, the confidence, the
identifier corroboration), and both graph views re-derive from the same
ledger on their next read. The by-user graph's provenance timeline is
where that row lands; the merge history and 30-minute undo window
described in
[Identity resolution and merge semantics](/concepts/cdp-identity-resolution)
govern the fold itself. Reads never queue candidates — every graph GET
is a projection over decisions that have already happened, so a graph
can only ever show a merge after it propagated, not as it is being
proposed.

## Worked examples — script both graphs end-to-end

### 1. By-user identity graph

Anchor on the resolved user id (or a `cnt_` contact id) and read the
stitched identifiers plus their fold provenance:

<RequestExample>
  ```bash theme={null}
  curl "https://api.orbit.devotel.io/api/v1/cdp/profiles/by-user-id/crm-48291/identity-graph?max_events=100" \
    -H "X-API-Key: dv_live_sk_your_key_here"
  ```
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "contact_id": "cnt_01HXW7N2QF",
      "nodes": [
        { "id": "contact:cnt_01HXW7N2QF", "kind": "contact", "value": "cnt_01HXW7N2QF", "is_root": true, "deleted": false },
        { "id": "external_id:crm-48291", "kind": "external_id", "value": "crm-48291", "is_root": false, "deleted": false },
        { "id": "email:jane@example.com", "kind": "email", "value": "j***e@example.com", "is_root": false, "deleted": false },
        { "id": "contact:cnt_01HXW9P4CD", "kind": "merged_contact", "value": "cnt_01HXW9P4CD", "is_root": false, "deleted": true }
      ],
      "edges": [
        { "from": "contact:cnt_01HXW7N2QF", "to": "external_id:crm-48291", "kind": "has_identifier" },
        { "from": "contact:cnt_01HXW7N2QF", "to": "email:jane@example.com", "kind": "has_identifier" },
        { "from": "contact:cnt_01HXW9P4CD", "to": "contact:cnt_01HXW7N2QF", "kind": "merge", "action": "contact_merge", "reason": "review-queue accept", "actor_id": "usr_01ABC", "at": "2026-08-30T09:12:00Z" }
      ],
      "provenance": [
        { "id": "iat_01HZ1", "action": "alias_linked", "source_contact_id": null, "target_contact_id": "cnt_01HXW7N2QF", "new_external_id": "crm-48291", "reason": "identify call", "actor_id": null, "at": "2026-08-14T18:03:11Z" },
        { "id": "iat_01HZ9", "action": "contact_merge", "source_contact_id": "cnt_01HXW9P4CD", "target_contact_id": "cnt_01HXW7N2QF", "new_external_id": null, "reason": "review-queue accept", "actor_id": "usr_01ABC", "at": "2026-08-30T09:12:00Z" }
      ],
      "stats": { "identifier_count": 2, "merged_contact_count": 1, "event_count": 2 },
      "truncated": false
    },
    "meta": { "request_id": "req_01JB2XK4M", "timestamp": "2026-09-01T10:15:00Z" }
  }
  ```
</ResponseExample>

Identifier values in the response render masked on tenants in the
default mask-on-return PII posture; the envelope still carries the full
node/edge/provenance/stats structure either way.

### 2. By-anonymous device graph

Anchor on the pre-identify `anonymousId` and bound the hop-1 fan-out
window:

<RequestExample>
  ```bash theme={null}
  curl "https://api.orbit.devotel.io/api/v1/cdp/profiles/by-anonymous-id/an_8f3a1c9e2d/device-graph?window_days=14" \
    -H "X-API-Key: dv_live_sk_your_key_here"
  ```
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "anonymous_id": "an_8f3a1c9e2d",
      "nodes": [
        { "id": "anonymous_id:an_8f3a1c9e2d", "kind": "anonymous_id", "value": "an_8f3a1c9e2d", "is_seed": true, "last_seen": "2026-09-01T09:44:00Z" },
        { "id": "device_id:dev_A1B2", "kind": "device_id", "value": "dev_A1B2", "is_seed": false, "last_seen": "2026-09-01T09:44:00Z" },
        { "id": "anonymous_id:an_0b7c4e1a", "kind": "anonymous_id", "value": "an_0b7c4e1a", "is_seed": false, "last_seen": "2026-08-29T21:10:00Z" },
        { "id": "user_id:crm-48291", "kind": "user_id", "value": "crm-48291", "is_seed": false, "last_seen": "2026-08-29T21:12:00Z" }
      ],
      "edges": [
        { "from": "anonymous_id:an_8f3a1c9e2d", "to": "device_id:dev_A1B2", "kind": "seen_on_device", "last_seen": "2026-09-01T09:44:00Z" },
        { "from": "anonymous_id:an_0b7c4e1a", "to": "device_id:dev_A1B2", "kind": "seen_on_device", "last_seen": "2026-08-29T21:10:00Z" },
        { "from": "anonymous_id:an_0b7c4e1a", "to": "user_id:crm-48291", "kind": "identified_as", "last_seen": "2026-08-29T21:12:00Z" }
      ],
      "linked_anonymous_ids": ["an_0b7c4e1a"],
      "stats": { "device_count": 1, "cookie_count": 0, "linked_anonymous_id_count": 1 },
      "truncated": false
    },
    "meta": { "request_id": "req_01JB3BN2P", "timestamp": "2026-09-01T10:16:00Z" }
  }
  ```
</ResponseExample>

Read `identified_as` links first: a shared device hint plus a later
`identify` on one of the sessions is the strongest confirmation that the
sessions belong to one visitor, short of an explicit alias.

### 3. Read and update the identity rules

Both graph reads resolve through the active rule chain; fetch the chain
before you debug a missing match:

<CodeGroup>
  ```bash cURL theme={null}
  curl "https://api.orbit.devotel.io/api/v1/cdp/identity-rules" \
    -H "X-API-Key: $ORBIT_API_KEY"

  curl -X PUT "https://api.orbit.devotel.io/api/v1/cdp/identity-rules" \
    -H "X-API-Key: $ORBIT_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"match_type": "email", "priority": 2, "enabled": true}'
  ```
</CodeGroup>

The PUT upserts one rule keyed on its `match_type`, so re-PUTting an
existing type updates it in place. An off-schema body — an unknown
match type or a missing field — returns a 422 field-level breakdown,
not an unchecked write.

## Privacy guardrails

Both graph endpoints are read-only GETs that take an *id* in the URL
path — a resolved user id / contact id, or a platform-minted
`anonymousId` — never an email, phone number, or name. PII you can
paste lives in request bodies, not paths; the URL stays in access logs
without leaking person identifiers. Response identifier values render
masked wherever the tenant's PII posture defaults to mask-on-return,
and the device graph handles only platform-issued opaque hints. Access
to either graph requires a role scoped for contacts
(`contacts:read` — owner, admin, or developer on an API key), and
reads rate-limit at 60 per minute per tenant. The erase path from
[CDP erasure propagation](/guides/cdp-erasure-propagation) applies to
the underlying contacts and events the graphs read; a graph is a
projection, never a second copy to purge separately.

## Cross-references

* [Identity resolution and merge semantics](/concepts/cdp-identity-resolution) —
  the deterministic/probabilistic pipeline both graphs project from.
* [CDP data catalog and identity rules (guide)](/guides/cdp-data-catalog-and-identity-rules) —
  the operator walkthrough of the rules editor surface.
* [CDP API reference](/api-reference/endpoints/cdp) — per-endpoint
  schemas for every call named above.
