# Identify users

> Set a user's traits, email address and push tokens with POST /v1/identify, and link anonymous events to the user.

Use `POST /v1/identify` to tell Whisperr who a user is: traits that give messages context, and the contact channels Whisperr may use to reach them. It also links events that the user sent before login to the user.

```text
POST /v1/identify
```

Identify is idempotent. Call it after login, and on every app start or session restore while a user is signed in. This also covers users who signed in before you added Whisperr. Traits are merged into the stored ones.

> The server key can always opt an email address in. The publishable key can opt a new address in only under your answer to **Does your signup get permission to email users?** Otherwise Whisperr stores it as pending. See [Contact consent](/concepts/consent/).

## Send an identify request

This request comes from your backend with the server key, after the user agreed to email:

```bash
curl https://api.whisperr.net/v1/identify \
  -H "X-API-Key: $WHISPERR_SERVER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "external_user_id": "user_8842",
    "traits": { "first_name": "Ada", "plan": "pro", "timezone": "Europe/Berlin" },
    "channels": [
      { "channel": "email", "address": "ada@example.com", "opted_in": true, "verified": true }
    ]
  }'
```

A valid request returns `202 Accepted`:

```json
{ "processing": "queued" }
```

Whisperr applies the traits and channels right after, normally within seconds. Identify and events are applied in the order Whisperr accepted them, so an event sent after an identify sees its result.

## The identify object

### `external_user_id` string

Your own stable user id: the same one you send on [events](/api/events/). Required, except in an [anonymous identify](#anonymous-identify).

### `anonymous_id` string

The handle this device sent events under before login, 1 to 128 printable characters. With `external_user_id`, Whisperr moves the handle’s events, activity and push tokens to the identified user. An unknown handle is not an error. The browser SDK sends it for you.

### `traits` object

Free-form user attributes, up to 50 KB as JSON. Omit the field when empty. Send only what messages need, such as `first_name` for a greeting. Do not send a full name, last name, email address or phone number as a trait. Send contact addresses as `channels`.

Three trait keys are reserved. Whisperr reads them to decide when to send and in which language:

| Key            | Format                             | Used for                                                                            |
| -------------- | ---------------------------------- | ----------------------------------------------------------------------------------- |
| `timezone`     | IANA name, such as `Europe/Berlin` | Quiet hours and send time. Without it, Whisperr uses UTC.                           |
| `locale`       | BCP 47 tag, such as `de-DE`        | The device language. A fallback for the message language.                           |
| `app_language` | BCP 47 tag, such as `de`           | The language the user picked in your product. Whisperr writes messages in it first. |

The client SDKs fill in `timezone` and `locale` from the device. Server SDKs never guess them. Put these keys inside `traits`: a top-level `timezone` is an unknown field and fails the request.

### Which traits does Whisperr drop?

Whisperr does not store the traits below. The request still succeeds.

| Trait                                                   | Example                                                                               | What Whisperr does                                                    |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| Value is a secret                                       | A card number, an IBAN, a JWT, or an API key in a known format (`sk_live_…`, `ghp_…`) | Never stores it.                                                      |
| Key is a secret key and the value is a string or number | `password`, `card_number`, `iban`, `ssn`                                              | Never stores it.                                                      |
| Value holds an email address or is a phone number       | `"ada@example.com"`, `"+44 7700 900123"`                                              | Does not store it as a trait. Send an email address in `channels`.    |
| Key is a special-category key                           | `religion`, `mental_health`, `medical_history`, `biometric_data`                      | Does not store it, unless a workspace owner or admin allows that key. |

Whisperr checks each nested field and array item too. It removes only the field that matches.

A key name alone does not make a trait a contact address: `"marketing_email": true` and `"mail": "weekly"` are stored.

#### Exact key lists

Whisperr compares keys after it changes them to lower case and changes `camelCase`, `-`, `.` and spaces to `_`. So `mentalHealth` and `mental-health` both match `mental_health`.

- **Secret keys:** `password`, `passwd`, `passcode`, `pin_code`, `card_number`, `cardnumber`, `credit_card`, `creditcard`, `cc_number`, `cvv`, `cvc`, `card_cvc`, `iban`, `account_number`, `bank_account`, `bank_account_number`, `routing_number`, `sort_code`, `ssn`, `social_security_number`, `national_id`, `national_insurance_number`, `passport`, `passport_number`, `tax_id`, `driver_license`, `drivers_license`, `driving_licence`, `personal_id_number`, and any key that ends in `_password`.
- **Special-category keys:** `health_condition`, `health_conditions`, `mental_health`, `diagnosis`, `diagnoses`, `medication`, `medications`, `disability`, `pregnancy`, `religion`, `sexuality`, `sexual_orientation`, `sex_life`, `gender_identity`, `ethnicity`, `racial_origin`, `trade_union`, `biometric_data`, and any key that starts with `medical_`, `religious_`, `ethnic_`, `political_`, `trade_union_` or `genetic_`.

Keys that are not on these lists are stored. This includes `health`, `politics`, `race`, `biometric` and `genetic`: many products use these words for other data, such as hit points or a news topic. Do not send special-category data under a key that is not on the list.

Whisperr records each dropped key, the reason and a count. It never records the value. A workspace owner or admin can read this list with the Whisperr MCP tool `list_traits` (see [Use with your AI agent](/agents/)).

### `preferred_channel` string

`email` or `push`. Optional. When it is set and the channel can reach the user, Whisperr uses it for that user. Without it, Whisperr picks the channel. Set it only to record a choice the user made.

### `channels` array

Up to 10 contact channels. Each item:

| Field              | Type                           | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| ------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `channel`          | string, required               | `email` or `push`. The field is named **`channel`**, not `type`. The API rejects unknown fields, so `type` fails the whole request with a `400`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `address`          | string, required               | An email address, or a push token.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `opted_in`         | bool, optional                 | `false` records an opt-out with any key: Whisperr will not use that address. With the **server key**, an omitted `opted_in` opts in a new email address and confirms a pending one. An address that already holds a decision keeps it. With the **publishable key**, a new email address is opted in only under your consent answer: **Yes** opts it in unless `opted_in` is `false`, **Per user** only when it is `true` and `consent_source` is `"user"`, and **No** stores it as pending. **Require a server key** makes every one pending. An earlier opt-out, including the unsubscribe link and bounce or complaint suppression, is kept. A later identify from the app never opts the address back in. The limits in [Contact consent](/concepts/consent/#limits-on-addresses-from-your-app) apply. |
| `consent_source`   | string, optional, `email` only | `"user"` when the user agreed to email in your app. Send it with `opted_in: true`. **Per user** needs it. The client SDKs send it for `emailOptedIn: true`. Whisperr ignores any other value.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `verified`         | bool, optional                 | Only the **server key** can verify an address. Whisperr ignores `verified` from the publishable key. With the server key, an omitted `verified` counts as `true`, unless the address was last declared `false`. Whisperr does not email an address that your server declared unverified.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `kind`             | string, optional, `push` only  | `fcm`, `apns`, `expo` or `onesignal_sub`: the token type. It tells Whisperr which provider can send to the token. Without it, Whisperr infers it from the token: `ExponentPushToken[…]` or `ExpoPushToken[…]` is `expo`, 64 hex characters is `apns`, anything else is `fcm`. Send a OneSignal subscription id with `kind: "onesignal_sub"`.                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `platform`         | string, optional, `push` only  | `ios`, `android`, `web`, `macos`, `windows` or `linux`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `push_env`         | string, optional, `push` only  | `production` or `sandbox`: the APNs environment of the token. Send it with `kind: "apns"`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `client_app_id`    | string, optional, `push` only  | The app that owns the token: iOS bundle id, Android package name or web origin.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `provider_project` | string, optional, `push` only  | The provider project of the token: Firebase project id (`fcm`), EAS project id (`expo`) or OneSignal app id (`onesignal_sub`). Never for `apns`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |

Push tokens follow the device permission instead. The mobile SDKs send a push token opted in only while the OS reports notification permission. See [Contact consent](/concepts/consent/#push-notifications).

The five push fields never fail a request. Whisperr drops a value it does not know, and drops the fields on an `email` channel. See [Connect a push provider](/integrations/push-providers/) for how Whisperr routes a token by its kind.

With the publishable key, an identify that gives a user a different email address than the one stored is rejected after acceptance. Change an existing address from your backend with the server key. See [Rejections after acceptance](/api/delivery/#rejections-after-acceptance).

## Anonymous identify

An identify with `anonymous_id` and no `external_user_id` stores data for a visitor you do not know yet. The SDKs use it to register a push token or an app language before login.

```json
{
  "anonymous_id": "3f2c9a1e-5b7d-4e8f-a0c1-2d3e4f5a6b7c",
  "traits": { "app_language": "de" },
  "channels": [
    { "channel": "push", "address": "<device token>", "opted_in": true, "kind": "fcm", "platform": "android" }
  ]
}
```

- It accepts only `traits` and `push` channels. An `email` channel or a `preferred_channel` in an anonymous identify is a `400`.
- A later identify with the same `anonymous_id` and an `external_user_id` moves the push tokens to the identified user. Traits from an anonymous identify do not move, so send them again on the identified call. The SDKs do this for `app_language`.
- When the handle already belongs to an identified user, Whisperr ignores the anonymous identify.

## SDK shortcuts

The client SDKs take `email` and `emailOptedIn` as a shortcut for the email channel. In an app, they run with the publishable key, so your answer to **Does your signup get permission to email users?** decides what happens:

`app.ts`

```ts
// In the app (publishable key). With "Yes", the address is opted in.
// With "Per user", only when emailOptedIn is true. With "No", it is pending.
whisperr.identify("user_8842", {
  traits: { first_name: "Ada", plan: "pro" },
  email: "ada@example.com",
  emailOptedIn: true,
});
```

An earlier opt-out, including the unsubscribe link and bounce or complaint suppression, is kept. A later identify from the app never opts the address back in. The shortcut needs a client SDK version with app-side consent. See [Contact consent](/concepts/consent/#does-your-signup-get-permission-to-email-users).

The server SDKs (Node, Python, PHP, .NET) behave differently: their `email` shortcut sends `opted_in: true`, so it opts the address in. Use it from your backend only when the user agreed to email:

`server.ts`

```ts
// On your backend (server key), after the user agreed to email.
whisperr.identify("user_8842", {
  channels: [{ type: "email", address: "ada@example.com", optedIn: true, verified: true }],
});
```

The SDKs take `type` and send it as the wire field `channel`. The backend call is the same request as the curl example above, without `traits`.

## Common questions

### When should I call identify?

After login, and on every app start or session restore while a user is signed in. Identify is idempotent, and a call that repeats what Whisperr already stores changes nothing.

### Can the publishable key opt a user in to email?

Only when you told Whisperr that your signup gets permission to email users. With Yes, a new address from the app is opted in unless opted_in is false. With Per user, only when opted_in is true and consent_source is "user". With No, or with Require a server key on, it is stored as pending until your backend confirms it with the server key.

### Why does identify fail with 400 when I send type in a channel?

The wire field is channel, not type. The Whisperr API rejects unknown fields, so type fails the whole request. The SDKs take type in their own API and send channel.

### Why was my trait not stored?

Whisperr drops traits that hold secrets, contact addresses or special-category data. The request still succeeds. The Whisperr MCP tool list_traits shows each dropped key and the reason.

Source: https://docs.whisperr.net/api/identify/
