---
title: "Profiles API"
description: "Identify users, read and list profiles, and run data-subject deletion across the SDK and shared profile services."
---

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

# Profiles API

Pug maintains a profile for every user your project tracks — identified or anonymous. Profiles accumulate traits (properties) and are linked to the events they generate.

Two RPC services cover the full lifecycle:

| Service | Auth | What it does |
|---------|------|--------------|
| `sdk.profiles.v1.ProfilesSDKService` | `x-api-key` (SDK key) | Write path — create or update a profile from the SDK |
| `shared.profiles.v1.ProfilesService` | Dual (`prv_…` or JWT) | Read / manage — query, list, delete |

See [Authentication](/get-started/authentication) and the [auth matrix](/api#auth-matrix) for key types and headers.

## `ProfilesSDKService` — write path

```
POST /sdk.profiles.v1.ProfilesSDKService/Identify
```

Creates or updates a profile identified by `external_id`. If the profile already exists, traits are **shallow-merged** — provided keys overwrite stored values for those keys, other keys are left untouched.

> **Traits are plain JSON, not `PropertyValue`-wrapped.** Unlike event `customProperties` (which use `{ "intValue": "5" }`-style wrappers), profile `traits` take bare scalars — see [`traits` type](#traits-type--plain-json-not-propertyvalue) below and the [Events API](/api/events).

**Auth:** `x-api-key` header with a public key (`pub_…`) or private key (`prv_…`). The project is resolved from the key — no `Authorization` header or `x-project-id` is required.

### `IdentifyRequest` fields

| Proto field | JSON name | Type | Required | Notes |
|---|---|---|---|---|
| `external_id` | `externalId` | string | Yes | Stable user identifier — e.g. your database user ID or email. Must be non-empty |
| `traits` | `traits` | `google.protobuf.Struct` | No | Plain JSON object. Shallow-merged into existing traits. On key conflict these values win |
| `anonymous_id` | `anonymousId` | string | No | SDK-generated anonymous ID. Must be empty or start with `anon-`; max 255 characters. Send on first identify to merge prior anonymous activity |
| `device_id` | `deviceId` | string | No | Device identifier (UUID, max 36 characters) to attach to this profile. Mobile SDKs send it on first identify and on account switch — not on every call. Omit for web |

### `traits` type — plain JSON, not `PropertyValue`

`traits` is typed as `google.protobuf.Struct`, which serialises as a plain JSON object in Connect JSON — **not** the `PropertyValue`-wrapped format used by event `customProperties`. You do not need the `{ "stringValue": "…" }` wrapper:

```json
{
  "traits": {
"email": "user@example.com",
"plan": "pro",
"seats": 5,
"trial": false
  }
}
```

Compare with events (which use `PropertyValue`): `{ "stringValue": "pro" }`. Profiles use plain scalars.

### `anonymous_id` constraint

The field must be either empty (omitted) or a string starting with `anon-`. Any other value returns `invalid_argument`. The Web SDK generates IDs in this format automatically.

### Anonymous → identified merge

When you include `anonymous_id` on an `Identify` call, the server merges the anonymous profile into the identified one — attaching prior events and properties — then soft-deletes the anonymous record. **Send `anonymous_id` on the first identify only** (when the user signs in). You do not need a separate `alias()` call; there is no such method.

### `IdentifyResponse`

The response is empty (`{}`). Writes are asynchronous — the profile record may not be visible via `ProfilesService` for a few seconds after the call returns.

### Wire example

```bash
curl -X POST https://api.pugs.dev/sdk.profiles.v1.ProfilesSDKService/Identify \
  -H "Content-Type: application/json" \
  -H "Connect-Protocol-Version: 1" \
  -H "x-api-key: pub_…" \
  -d '{
"externalId": "user-123",
"anonymousId": "anon-abc456",
"traits": {
  "email": "user@example.com",
  "plan": "pro",
  "company": "Acme Inc"
}
  }'
```

For a full curl and language recipe see [HTTP transport — Identifying users](/sdks#identify-and-sessions).

## `ProfilesService` — read and manage

**Auth:** Dual — either a private key (`prv_…`) via `x-api-key`, or a dashboard JWT via `Authorization: Bearer`. Public keys are rejected on this path.

```http
x-api-key: prv_…
# — or —
Authorization: Bearer <jwt>
```

### Methods

| Method | Endpoint |
|--------|----------|
| `Get` | `POST /shared.profiles.v1.ProfilesService/Get` |
| `GetByExternalId` | `POST /shared.profiles.v1.ProfilesService/GetByExternalId` |
| `List` | `POST /shared.profiles.v1.ProfilesService/List` |
| `Delete` | `POST /shared.profiles.v1.ProfilesService/Delete` |

### `Get`

Fetch a profile by its internal Pug ID.

**Request**

| Field | Type | Required | Notes |
|---|---|---|---|
| `id` | string | Yes | Internal profile ID (as returned by `List` or `GetByExternalId`) |

```json
{ "id": "01966e2a-e4b2-7000-9000-000000000001" }
```

**Response** — returns a [`Profile`](#profile-message) object.

```json
{
  "profile": {
"id": "01966e2a-e4b2-7000-9000-000000000001",
"externalId": "user-123",
"properties": { "email": "user@example.com", "plan": "pro" },
"projectId": "proj_abc",
"createTime": "2026-01-15T10:00:00Z",
"updateTime": "2026-06-01T14:30:00Z",
"activity": {
  "firstSeen": "2026-01-15T10:00:00Z",
  "lastSeen": "2026-06-01T14:30:00Z",
  "totalEvents": 342,
  "pageviews": 198,
  "sessions": 27,
  "browser": "Chrome",
  "os": "macOS",
  "country": "US"
}
  }
}
```

### `GetByExternalId`

Fetch a profile by the `external_id` you passed to `Identify`.

**Request**

| Field | Type | Required | Notes |
|---|---|---|---|
| `external_id` | string | Yes | The stable user identifier set on `Identify` |

```json
{ "externalId": "user-123" }
```

**Response** — same shape as `Get`.

### `List`

Search and page through profiles. `List` is **server-streaming** — the server may return results in multiple response frames over a single HTTP/2 stream (or chunked HTTP/1.1). Each frame carries a batch of profiles and a `next_page_token`.

**Request**

| Field | JSON name | Type | Notes |
|---|---|---|---|
| `page_token` | `pageToken` | string | Opaque cursor from a previous response's `nextPageToken`. Empty (or omitted) for the first page |
| `filter_groups` | `filterGroups` | `FilterGroup[]` | Zero or more filter groups. Each group has `filters` (array of `PropertyFilter`) and an `operator` (`LOGICAL_OPERATOR_AND` or `LOGICAL_OPERATOR_OR`) to combine within the group |
| `filter_groups_operator` | `filterGroupsOperator` | `LogicalOperator` | How to combine multiple `filterGroups` — `LOGICAL_OPERATOR_AND` or `LOGICAL_OPERATOR_OR`. Defaults to `LOGICAL_OPERATOR_AND` when unspecified |

Each `PropertyFilter` has three fields: `property` (the trait key, e.g. `"plan"`), `operator` (full enum form — `FILTER_OPERATOR_EQUALS`, `FILTER_OPERATOR_NOT_EQUALS`, `FILTER_OPERATOR_CONTAINS`, `FILTER_OPERATOR_NOT_CONTAINS`, `FILTER_OPERATOR_IS_SET`, `FILTER_OPERATOR_IS_NOT_SET`, `FILTER_OPERATOR_LTE`, `FILTER_OPERATOR_GTE`, `FILTER_OPERATOR_LT`, `FILTER_OPERATOR_GT`, `FILTER_OPERATOR_IN`, `FILTER_OPERATOR_NOT_IN`, `FILTER_OPERATOR_BETWEEN`, `FILTER_OPERATOR_NOT_BETWEEN`; see [Insights → Filter operators](/api/insights#filter-operators-filteroperator)), and `value`/`values` (string or string array for multi-value operators).

**Example — filter by plan**

```json
{
  "filterGroups": [
{
  "filters": [
    { "property": "plan", "operator": "FILTER_OPERATOR_EQUALS", "value": "pro" }
  ],
  "operator": "LOGICAL_OPERATOR_AND"
}
  ]
}
```

**Response** (streamed)

```json
{
  "profiles": [ { "id": "…", "externalId": "user-123", "properties": { "plan": "pro" }, "…": "…" } ],
  "nextPageToken": "eyJvZmZzZXQiOjUwfQ"
}
```

Pass `nextPageToken` as `pageToken` in your next request. An empty `nextPageToken` means no more pages.

### `Delete`

Soft-deletes a profile by its internal ID. The profile is hidden from `List` and `GetByExternalId` but event data in ClickHouse is retained.

**Request**

| Field | Type | Required | Notes |
|---|---|---|---|
| `id` | string | Yes | Internal profile ID |

```json
{ "id": "01966e2a-e4b2-7000-9000-000000000001" }
```

**Response** — empty (`{}`).

## `Profile` message

Returned by `Get`, `GetByExternalId`, and each frame of `List`.

| Proto field | JSON name | Type | Notes |
|---|---|---|---|
| `id` | `id` | string | Internal Pug profile ID |
| `external_id` | `externalId` | string | Stable user identifier set via `Identify` |
| `properties` | `properties` | `google.protobuf.Struct` | Trait map — plain JSON object (same encoding as `IdentifyRequest.traits`) |
| `project_id` | `projectId` | string | Project this profile belongs to |
| `create_time` | `createTime` | RFC 3339 timestamp | When the profile was first created |
| `update_time` | `updateTime` | RFC 3339 timestamp | When traits were last updated |
| `activity` | `activity` | `ProfileActivitySummary` | Aggregated activity data (see below) |

### `ProfileActivitySummary` fields

| Proto field | JSON name | Type | Notes |
|---|---|---|---|
| `first_seen` | `firstSeen` | RFC 3339 timestamp | Earliest event timestamp for this profile |
| `last_seen` | `lastSeen` | RFC 3339 timestamp | Most recent event timestamp |
| `total_events` | `totalEvents` | int64 | All events attributed to this profile |
| `pageviews` | `pageviews` | int64 | Page-view event count |
| `sessions` | `sessions` | int64 | Session count |
| `browser` | `browser` | string | Most recently seen browser name |
| `browser_version` | `browserVersion` | string | Browser version |
| `os` | `os` | string | Operating system name |
| `os_version` | `osVersion` | string | OS version |
| `device` | `device` | string | Device type or model |
| `country` | `country` | string | ISO 3166-1 alpha-2 country code |
| `region` | `region` | string | Region / state |
| `city` | `city` | string | City name |

Activity fields are derived from ClickHouse event data. They reflect writes with a short lag — typically a few seconds after `Identify` returns.

## Errors

| Code | HTTP | Cause |
|------|------|-------|
| `unauthenticated` | 401 | Missing, malformed, or expired credential |
| `permission_denied` | 403 | Public key used on a `shared.*` endpoint |
| `invalid_argument` | 400 | `external_id` empty; `anonymous_id` does not match `^$\|^anon-` or exceeds 255 characters; `device_id` exceeds 36 characters |
| `not_found` | 404 | No profile with the given `id` or `external_id` |
| `internal` | 500 | Server error — retry with exponential backoff |

## Further reading

- [HTTP transport](/sdks#identify-and-sessions) — curl and language recipes for `Identify`
- [Authentication](/get-started/authentication) — public vs. private keys, key rotation
- [Auto-properties](/reference/auto-properties) — `$`-prefixed properties set by the server on events
- [Events API](/api/events) — `PropertyValue`-wrapped properties used by `BatchCreate`

Source: https://docs.pug.sh/api/profiles/index.mdx
