Profiles
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 and the auth matrix for key types and headers.
ProfilesSDKService — write path
POST /sdk.profiles.v1.ProfilesSDKService/IdentifyCreates 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 eventcustomProperties(which use{ "intValue": "5" }-style wrappers), profiletraitstake bare scalars — seetraitstype below and the Events API.
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:
{
"traits": {
"email": "[email protected]",
"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
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": "[email protected]",
"plan": "pro",
"company": "Acme Inc"
}
}'For a full curl and language recipe see HTTP transport — Identifying users.
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.
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) |
{ "id": "01966e2a-e4b2-7000-9000-000000000001" }Response — returns a Profile object.
{
"profile": {
"id": "01966e2a-e4b2-7000-9000-000000000001",
"externalId": "user-123",
"properties": { "email": "[email protected]", "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 |
{ "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), and value/values (string or string array for multi-value operators).
Example — filter by plan
{
"filterGroups": [
{
"filters": [
{ "property": "plan", "operator": "FILTER_OPERATOR_EQUALS", "value": "pro" }
],
"operator": "LOGICAL_OPERATOR_AND"
}
]
}Response (streamed)
{
"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 |
{ "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 — curl and language recipes for
Identify - Authentication — public vs. private keys, key rotation
- Auto-properties —
$-prefixed properties set by the server on events - Events API —
PropertyValue-wrapped properties used byBatchCreate