> ## Documentation Index
> Fetch the complete documentation index at: https://flywheel.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# People

> Find, create, edit and group the people in a Flywheel organization through the MCP server: identifiers, custom properties, segments, opt-outs and subscriptions.

A person is one contact in your Flywheel organization: someone who signed up for your product, paid you, registered for a webinar or was imported. Every other tool family (workflows, broadcasts, webinars, signals) works on people, so resolving the right person is usually the first step of any task.

This page covers every tool in the people family on the Flywheel MCP server at `https://api.flywheel.cx/mcp`. For connecting a client and how permissions work, see [/mcp/overview](/mcp/overview).

## The people tools at a glance

| Tool | Permission | What it does |
| - | - | - |
| `search_people` | `people.read` | Case-insensitive substring search over identifier values (email, phone, external id, and so on). |
| `list_people` | `people.read` | Exact filters, sorting and pagination over everyone in the org. |
| `get_person` | `people.read` | One person in full. |
| `identify_person` | `people.write` | Find a person by identifier, or create them if none matches. |
| `delete_person` | `people.delete` | Permanently delete one person. |
| `list_identifiers` | `people.read` | Every identifier attached to a person. |
| `add_identifier` / `update_identifier` / `remove_identifier` | `people.write` | Edit a person's identifiers. |
| `list_property_configs` | `people.read` | The org's custom property definitions. |
| `create_property_config` / `update_property_config` / `delete_property_config` | `people.write` | Manage custom property definitions. |
| `get_person_properties` | `people.read` | Custom property values on one person. |
| `set_person_property` / `delete_person_property` | `people.write` | Write or remove one custom property value on one person. |
| `list_segments` / `get_segment` / `list_segment_members` | `people.read` | Read segments and who is in them. |
| `create_segment` / `update_segment` / `delete_segment` | `people.write` | Manage segments. |
| `list_subscriptions` | `people.read` | A person's payment-processor subscriptions (read-only). |
| `get_opt_outs` / `set_opt_outs` | `people.read` / `people.write` | Marketing email and SMS opt-outs. |
| `create_import` / `set_import_mapping` / `append_import_rows` | `people.write` | Build a people import (a CSV or list) as a checked draft. |
| `preview_import` | `people.read` | Dry run of an import: creates, updates, opt-outs, merges. Returns the commit token. |
| `commit_import` | `people.write` | Run a previewed import. |
| `get_import` / `cancel_import` | `people.read` / `people.write` | Follow an import with per-row failure reasons; delete a draft. |
| `list_data_imports` | `people.read` | Import history. |

Every id these tools take (`person_id`, `segment_id`, `config_id`, `identifier_id`) is a UUID read from another tool's output. Never invent one.

## Finding a person: search\_people vs list\_people vs get\_person

Use the tool that matches what you already have.

* **You have something a human typed** (an email, part of an email, a phone number, an external id): call `search_people`.
* **You want everyone matching exact criteria** (all trialing users, everyone with MRR over 100, members of a segment): call `list_people`.
* **You already have the person's UUID** and need the full record: call `get_person`.
* **You are not sure the person exists yet and may need to create them**: call `identify_person` (see the next section).

### search\_people

`search_people` takes one `query` string and does a case-insensitive substring match against the values of every identifier in the org (emails, phones, external user ids, Stripe customer ids, anonymous ids, Slack ids, full-name identifiers). It returns up to 50 matching people as summaries, in no particular order, with no `total`.

```json theme={null}
{ "query": "jane@acme.com" }
```

```json theme={null}
{ "query": "acme.com" }
```

The second call returns people at that domain, capped at 50. `search_people` only looks at identifier values. It does not search the `first_name` / `last_name` fields on the person, so a name search only finds people who have a `full_name` identifier. If a name search comes back empty, search by email instead. An empty `query` is rejected.

Each result is a summary: `id`, `first_name`, `last_name`, `primary_email`, `primary_phone`, `external_user_id`, `subscription_status`, `payment_processor`, `mrr`, `ltv`, `created_at`. Call `get_person` for the rest.

### list\_people

`list_people` applies exact filters, all optional and ANDed together, and returns `{ people, total }` where `total` is the full count matching the filters.

| Argument | Meaning |
| - | - |
| `subscription_status` | One of `incomplete`, `incomplete_expired`, `trialing`, `active`, `past_due`, `canceled`, `unpaid`, `paused`, `inactive`. |
| `payment_processor` | `stripe`, `paddle` or `whop`. |
| `health_band` | `critical`, `low`, `moderate`, `healthy` or `power`. |
| `external_user_id` | Exact match on your product's own user id for the person. |
| `email` | Exact match on primary email (trimmed and lowercased before matching). |
| `identified` | `true` returns only people with a primary email, a first name or a last name. |
| `segment_id` | Only current members of this segment (from `list_segments`). |
| `mrr_gte`, `mrr_lte`, `ltv_gte`, `ltv_lte` | Numeric ranges, inclusive. |
| `created_after`, `created_before` | ISO 8601 timestamps, inclusive. |
| `sort` | `created_at` (default), `mrr`, `ltv`, `first_name`, `last_name`, `primary_email`, `last_opened_at`, `last_clicked_at`. |
| `order` | `asc` or `desc` (default `desc`). |
| `limit` | Page size, 1 to 200, default 50. |
| `offset` | Rows to skip, default 0. |

An enum value outside the allowed list is rejected with the list of valid values. Paging is stable: ties on the sort field are broken by id, so offset paging through a bulk import (where many people share one `created_at`) neither skips nor repeats anyone.

```json theme={null}
{
  "subscription_status": "active",
  "mrr_gte": 100,
  "sort": "mrr",
  "order": "desc",
  "limit": 100
}
```

`segment_id` composes with every other filter, so "members of the Power Users segment who are past due" is one call:

```json theme={null}
{ "segment_id": "3f0c1a2e-8a4b-4d4e-9b1f-0c2d3e4f5a6b", "subscription_status": "past_due" }
```

### get\_person

`get_person` takes a `person_id` and returns `{ person }` with:

* names, `primary_email`, `primary_phone`, `primary_contact_method`, `external_user_id`
* subscription standing: `subscription_status`, `payment_processor`, `mrr`, `ltv`, `first_payment_date`, `next_payment_date`, `cancels_at`
* engagement: `email_engagement_score`, `last_opened_at`, `last_clicked_at`, `last_replied_at`
* `custom_properties`, keyed by property key, each entry carrying the value and the property's name and type
* `contact_info`: the primary and all contact methods
* `team_members`: `{ primary_team_member, all_assigned }` as user ids
* `created_at`, `updated_at`

```json theme={null}
{ "person_id": "9b2d4c1a-5e6f-4a7b-8c9d-0e1f2a3b4c5d" }
```

A person id from another organization returns not found.

## identify\_person: find or create a person

`identify_person` resolves a person from any identifier and creates them if nothing matches. It runs through the same identify pipeline Flywheel's integrations use, including duplicate-contact merging, so a person identified here is indistinguishable from one identified by your product's event stream. It is idempotent: calling it again with the same identifiers returns the same person.

Use `identify_person` instead of `search_people` when the task may involve someone who is not in Flywheel yet (a new signup, a lead from a form, a person you are about to register for a webinar).

### Arguments

| Argument | Meaning |
| - | - |
| `email` | Trimmed and lowercased. |
| `phone` | Trimmed. |
| `external_user_id` | Your product's own user id for this person. |
| `stripe_customer_id` | A Stripe customer id. |
| `anonymous_id` | An anonymous/device id from your tracking. |
| `first_name`, `last_name` | Names to store on the person. |
| `custom_properties` | An object of key to value (string, number or boolean). |

At least one of `email`, `phone`, `external_user_id`, `stripe_customer_id` or `anonymous_id` is required. Names alone cannot identify anyone.

The result is `{ person_id, created }`. `created` is `true` when no existing person matched any identifier you sent.

```json theme={null}
{
  "email": "jane@acme.com",
  "first_name": "Jane",
  "last_name": "Doe",
  "external_user_id": "usr_48213"
}
```

### Which keys resolve a person

The pipeline looks for an existing person by `external_user_id` first. If that finds nobody, it tries the other identifiers in this order and stops at the first match: `stripe_customer_id`, `email`, `anonymous_id`, `phone`. The identifiers you sent are then attached to the person it found (or created). If one of them already belongs to a different person, the two contacts are merged and the surviving `person_id` is returned.

### external\_user\_id over MCP

`external_user_id` must be the org's real user id for this person, read from their product, never a guess. Over MCP it is attached as an identifier but never replaces the external id a person already has, including a person whose external id is still their email address. Changing a person's external id requires the REST People API with an org API key.

### custom\_properties on identify\_person

`custom_properties` passed to `identify_person` behave differently from `set_person_property`:

* They are applied asynchronously after the person is resolved, so read them back a moment later rather than immediately.
* Every key must already have a property config. A key with none is refused with an error naming it, and nothing is written. Create the config first with `create_property_config`. (Over MCP a mistyped key would otherwise create a junk property in every picker for good.)
* Keys are normalized to snake\_case.
* A value for an existing config is converted to that config's type.
* Values that are not a string, number or boolean, and reserved keys (see [Custom properties](#custom-properties-configs-vs-values)), are dropped silently rather than failing the call.

If you want strict type checking on values too, write them with `set_person_property` instead. To bring in many people at once, use an import (below), never a loop of `identify_person` calls.

## delete\_person: permanently delete one person

`delete_person` hard-deletes one person. It cannot be undone. Resolve the person first with `search_people`, `list_people` or `identify_person`, and confirm with the person you are working for before calling it.

```json theme={null}
{
  "person_id": "9b2d4c1a-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "reason": "duplicate test contact, cleanup 2026-10-08"
}
```

`reason` is optional, at most 200 characters, and is recorded with the deletion. Every deletion is logged with the credential that made it (the API key, or the user who granted the MCP connection). The log holds ids and the credential only, no contact data. The result is `{ deleted: true, person_id }`.

### What goes and what stays

The person's events, sent-email history, opt-outs, email blacklist entries, subscriptions, webinar registrations and signals are deleted with them. Purchase rows and sent-message rows are kept with the person removed from them.

### When delete\_person refuses

`delete_person` refuses, and deletes nothing, when:

* **The person is suppressed.** They have an email opt-out, an SMS opt-out, or an email blacklist entry (a bounce or complaint). Those records would be deleted with the person, so if the same address were identified again later (a form, a sync, the pixel) it would come back as a new, mailable contact. The MCP tool has no override. Deleting a suppressed person on purpose, for example for an erasure request, is done through the REST People API (`DELETE /v1/people/:person_id` with `drop_suppression=true`).
* **Other records still point at the person**: tasks or connection invites. Remove those first.
* **The id is not a UUID**, or the person is not in this organization.

## Identifiers: list\_identifiers, add\_identifier, update\_identifier, remove\_identifier

An identifier is one way of recognizing or reaching a person: an email, a phone number, your product's user id, a Stripe customer id, a Slack id. A person can have many identifiers of each type. Each identifier has two flags:

* `is_contact_method`: whether Flywheel may send messages to it.
* `is_primary_for_type`: whether it is the person's primary identifier of that type. There is at most one primary per type per person.

Valid `identifier_type` values: `email`, `phone`, `user_id`, `org_assigned_user_id`, `stripe_customer_id`, `anonymous_id`, `slack`, `slack_user_id`, `slack_channel`, `telegram`, `full_name`.

### list\_identifiers

`list_identifiers` takes a `person_id` and returns `{ identifiers }`, oldest first, each with `id`, `identifier_type`, `identifier_value`, `is_contact_method`, `is_primary_for_type`, `metadata` and `created_at`.

```json theme={null}
{ "person_id": "9b2d4c1a-5e6f-4a7b-8c9d-0e1f2a3b4c5d" }
```

### add\_identifier

`add_identifier` attaches a new identifier to a person, through the same database function the dashboard uses. Emails are lowercased. `is_contact_method` and `is_primary_for_type` both default to `false`, so an email you add is not mailable unless you say so:

```json theme={null}
{
  "person_id": "9b2d4c1a-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "identifier_type": "email",
  "identifier_value": "jane.doe@acme.com",
  "is_contact_method": true,
  "is_primary_for_type": true
}
```

The result reports what happened: `action`, `is_primary`, `was_primary_updated`, `details`, and conflict fields `has_conflict`, `existing_identifier_id`, `existing_org_user_id`, `new_org_user_id`. If the value already belongs to another person, `has_conflict` is `true` and `existing_org_user_id` names who has it. Read the result instead of assuming the identifier landed on the person you passed.

### update\_identifier

`update_identifier` edits an identifier's value or flags. It cannot change the identifier's type. At least one field is required. Setting `is_primary_for_type: true` demotes the previous primary of that type first.

```json theme={null}
{
  "person_id": "9b2d4c1a-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "identifier_id": "c7e1f2a3-4b5c-4d6e-8f9a-0b1c2d3e4f5a",
  "is_primary_for_type": true
}
```

### remove\_identifier

`remove_identifier` deletes one identifier. The person stays; only that way of reaching or recognizing them goes.

```json theme={null}
{
  "person_id": "9b2d4c1a-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "identifier_id": "c7e1f2a3-4b5c-4d6e-8f9a-0b1c2d3e4f5a"
}
```

## Custom properties: configs vs values

Custom properties are fields your organization defines on people beyond the built-in ones: a plan name, a company size, whether someone finished onboarding. They come in two parts:

* A **property config** is the org-wide definition: `key`, display `name`, `type`, optional `description`. One per property.
* A **property value** is one person's value for one config.

Custom properties belong to you, the customer. They are set by your product's identify calls, the API and MCP, CSV imports, form integrations and workflows. Do not use them to copy data Flywheel already keeps as built-in person fields (`subscription_status`, `mrr`, `ltv`, payment dates, engagement dates, health band). Filter on those directly in `list_people` or a segment. Every config you create shows up in every property picker, broadcast variable list and workflow vocabulary in the org, so create one only when a person needs to carry a fact Flywheel does not already have.

Types are `string`, `number` and `boolean`. There is no date type: store dates as ISO 8601 strings.

### list\_property\_configs

`list_property_configs` returns `{ property_configs }`, each with `id`, `key`, `name`, `description`, `type` and `created_at`. Call it before `set_person_property`, and before `create_segment` with a custom property filter, to get the exact key and type.

```json theme={null}
{}
```

### create\_property\_config

`create_property_config` defines a new property. `key` must match `[A-Za-z0-9_]+` (use snake\_case) and be unique in the org; a duplicate key is rejected. `type` is `string`, `number` or `boolean`. Keys of the form `webinar_<outcome>_<session uuid>` are reserved by Flywheel and rejected.

```json theme={null}
{
  "key": "onboarding_completed",
  "name": "Onboarding completed",
  "type": "boolean",
  "description": "True once the person finishes the in-app setup checklist"
}
```

### update\_property\_config

`update_property_config` changes a config's `name` and/or `description`. `key` and `type` cannot change, because stored values and segment filters depend on them. To change either, create a new config.

```json theme={null}
{ "config_id": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d", "name": "Setup checklist done" }
```

### delete\_property\_config

`delete_property_config` deletes the config and every person's value for it. The result is `{ deleted: true, deleted_values }` with the number of values removed. The values cannot be recovered. Segments that filter on the deleted key are not removed; they keep running and simply match nobody on that condition.

```json theme={null}
{ "config_id": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d" }
```

### get\_person\_properties

`get_person_properties` returns `{ properties }` for one person, each with `key`, `value`, `property_config_id` and `created_at`.

```json theme={null}
{ "person_id": "9b2d4c1a-5e6f-4a7b-8c9d-0e1f2a3b4c5d" }
```

### set\_person\_property

`set_person_property` sets or overwrites one value on one person. The key must already have a config (create it with `create_property_config` first), and the value must match the config's type exactly: a `number` config takes `42`, not `"42"`. `null` clears the value but keeps the row.

```json theme={null}
{
  "person_id": "9b2d4c1a-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "key": "onboarding_completed",
  "value": true
}
```

### delete\_person\_property

`delete_person_property` removes one person's value for a key entirely. The config stays. It returns not found if the person has no value for that key.

```json theme={null}
{ "person_id": "9b2d4c1a-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "key": "onboarding_completed" }
```

## Segments

A segment is a saved, dynamic group of people defined by filter conditions. Membership is evaluated live every time it is read, by the same engine the dashboard and broadcast sends use, so a person joins or leaves a segment the moment their data changes. Segments are used as broadcast audiences (see [/mcp/broadcasts](/mcp/broadcasts)).

Segments with `is_system: true` are maintained by Flywheel (webinar audiences, for example). They can be read and listed, but `update_segment` and `delete_segment` refuse them.

### Segment filter shape

`create_segment` and `update_segment` take `filters`: an array of 1 to 20 conditions, all of which must match (AND). There is no OR between conditions. Each condition is:

```json theme={null}
{
  "property": "subscription_status",
  "op": "eq",
  "value": "trialing"
}
```

| Field | Meaning |
| - | - |
| `property` | A built-in person field (list below). Set this or `custom_property_key`, never both. |
| `custom_property_key` | The `key` of a custom property config, from `list_property_configs`. |
| `op` | The comparison (allowed ops depend on the field type, below). |
| `value` | The comparison value. Required for every op except `in`, `is_empty` and `is_not_empty`. |
| `values` | For `op: "in"`: a non-empty array of accepted strings. |

Built-in fields and their ops:

| Field type | Fields | Allowed ops |
| - | - | - |
| Number | `mrr`, `ltv`, `email_engagement_score`, `health_score`, `score_change_7d` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte` (value must be a number) |
| Date | `created_at`, `first_payment_date`, `next_payment_date`, `cancels_at`, `last_opened_at`, `last_clicked_at`, `last_replied_at` | `gt`, `gte`, `lt`, `lte` |
| Text | `first_name`, `last_name`, `primary_email`, `primary_phone`, `primary_contact_method`, `org_assigned_user_id` | `eq`, `neq`, `contains`, `in`, `is_empty`, `is_not_empty` |
| Fixed-value (enum) | `subscription_status`, `payment_processor`, `health_band`, `health_trend` | `eq`, `neq`, `in`, `is_empty`, `is_not_empty` (no `contains`) |

Custom property filters take their ops from the config's type:

| Config type | Allowed ops |
| - | - |
| `number` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte` (value must be a number) |
| `boolean` | `eq` only (value must be `true` or `false`) |
| `string` | `eq`, `neq`, `contains`, `in`, `is_empty`, `is_not_empty` |

Date values accept an ISO 8601 timestamp or a relative token that is resolved against the current time on every read: `now`, or `now` followed by `-` or `+`, a whole number and `d` (days) or `h` (hours). `now-7d` means seven days ago, so `{ "property": "last_opened_at", "op": "gte", "value": "now-7d" }` always means "opened in the last 7 days", not "since the day the segment was saved".

`contains` is a case-insensitive substring match: `{ "property": "primary_email", "op": "contains", "value": "@acme.com" }` matches every Acme address, and `%` or `_` in the value match themselves. A `neq` on a custom property also matches people who have no value for that property. An unknown built-in field, an op not allowed for the field, a value of the wrong type, or a `custom_property_key` with no config is rejected with an error that names the valid choices.

### list\_segments and get\_segment

`list_segments` returns `{ segments }`, newest first. `get_segment` takes a `segment_id` and returns `{ segment }`. Each segment has `id`, `name`, `description`, `color`, `is_system`, `created_at` and `filters` in the same shape `create_segment` takes. Segments built in the dashboard are translated back into this shape on a best-effort basis; the dashboard's "identified people" preset reads back as `{ "property": "identified", "op": "eq", "value": true }`, which you can read but cannot send to `create_segment`.

```json theme={null}
{ "segment_id": "3f0c1a2e-8a4b-4d4e-9b1f-0c2d3e4f5a6b" }
```

### list\_segment\_members

`list_segment_members` returns the people currently in a segment, newest first, as `{ people, total }`, where `total` is the full member count. Page with `limit` (1 to 200, default 50) and `offset`.

```json theme={null}
{ "segment_id": "3f0c1a2e-8a4b-4d4e-9b1f-0c2d3e4f5a6b", "limit": 200, "offset": 0 }
```

For segment members that also match other filters, or sorted differently, use `list_people` with `segment_id` instead.

### create\_segment

`create_segment` takes `name` (non-empty), optional `description`, optional `color` (a hex color such as `#6366f1` for the dashboard pill) and `filters`. It returns `{ segment }` with the conditions as stored. Check membership with `list_segment_members` after creating it.

```json theme={null}
{
  "name": "High-value customers going quiet",
  "description": "Paying 200+/mo, no email open in 30 days",
  "color": "#f97316",
  "filters": [
    { "property": "subscription_status", "op": "eq", "value": "active" },
    { "property": "mrr", "op": "gte", "value": 200 },
    { "property": "last_opened_at", "op": "lt", "value": "now-30d" }
  ]
}
```

### update\_segment

`update_segment` edits a segment's `name`, `description`, `color` or `filters`. Fields you leave out are unchanged. Passing `filters` replaces the entire condition set, so always send the complete list you want: read the current filters with `get_segment`, change them, and send them all back. System segments cannot be edited.

```json theme={null}
{
  "segment_id": "3f0c1a2e-8a4b-4d4e-9b1f-0c2d3e4f5a6b",
  "filters": [
    { "property": "subscription_status", "op": "in", "values": ["active", "past_due"] },
    { "property": "mrr", "op": "gte", "value": 200 },
    { "property": "last_opened_at", "op": "lt", "value": "now-30d" }
  ]
}
```

### delete\_segment

`delete_segment` deletes the segment definition. The people in it are untouched. System segments cannot be deleted.

```json theme={null}
{ "segment_id": "3f0c1a2e-8a4b-4d4e-9b1f-0c2d3e4f5a6b" }
```

## Subscriptions and opt-outs

### list\_subscriptions

`list_subscriptions` returns a person's subscriptions as mirrored from the payment processor (Stripe, Paddle or Whop), newest first: `id`, `status`, `item_names`, `cost`, `started_at`, `canceled_at`, `current_period_start`, `current_period_end`, `trial_start`, `trial_end`, `created_at`. It is read-only. Subscriptions change in the payment processor, never through Flywheel. The person-level rollup (`subscription_status`, `mrr`, `ltv`) is on `get_person` and filterable in `list_people` and segments.

```json theme={null}
{ "person_id": "9b2d4c1a-5e6f-4a7b-8c9d-0e1f2a3b4c5d" }
```

### get\_opt\_outs

`get_opt_outs` returns `{ opt_outs: { email_opt_out, sms_opt_out } }` for one person. These are marketing opt-outs. A person with no opt-out record is returned as both `false`, meaning fully opted in.

```json theme={null}
{ "person_id": "9b2d4c1a-5e6f-4a7b-8c9d-0e1f2a3b4c5d" }
```

### set\_opt\_outs

`set_opt_outs` sets a person's marketing opt-outs. Pass `email_opt_out`, `sms_opt_out`, or both; the one you leave out keeps its current value. Opting someone out stops marketing email or SMS to them; honor an unsubscribe request immediately. Only opt someone back in (set to `false`) after explicit consent from that person.

```json theme={null}
{ "person_id": "9b2d4c1a-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "email_opt_out": true }
```

An opt-out is also what stops `delete_person` from deleting the person, so their suppression is not lost.

## Importing people: create\_import → commit\_import

Use an import whenever you are bringing in more than a handful of people from a CSV, a spreadsheet or another tool. It writes through the same pipeline as a dashboard import, but every step is checked on the server first, so a wrong mapping is refused before it touches anyone.

1. **`create_import`** with `name`, `columns` (every column, as named in the rows) and up to 20 `sample_rows`. Parse the file yourself; rows are objects keyed by column. Nothing is written to people. The result describes each column (what the sample values look like, and a `suggested` target only when the header and the values agree) and lists every valid target, including the org's existing properties.
2. **`set_import_mapping`** with a target for **every** column:

   * a person field: `email`, `alternate_email`, `phone`, `alternate_phone`, `external_user_id`, `stripe_customer_id`, `anonymous_id`, `first_name`, `last_name` (names never overwrite an existing one);
   * a consent column: `subscribed_to_emails` (false/no means opted out) or `unsubscribed_from_emails` (true/yes means opted out). These only ever opt people **out**, never back in;
   * `{ "property": "<existing key>" }`;
   * `{ "new_property": { "key": "snake_case", "name": "...", "type": "string" | "number" | "boolean" } }` to declare a new property; it is created at commit, with that type;
   * `"skip"`.

   At least one identifier column is required. The call refuses unknown property keys (and names the closest existing one), system keys such as `email_valid`, two columns on one target, and sample values that do not fit the type. It saves only when there are no errors, and shows the first sample rows as the person each becomes.
3. **`append_import_rows`** with up to 500 rows per call (50,000 per import), in file order, one call at a time. Each row is checked against the mapping; a row that does not fit is rejected with its row number and reason and is not imported. Blank cells are ignored and never overwrite a stored value. Consent values must be a clear yes or no (`true/false`, `yes/no`, `1/0`, `subscribed/unsubscribed`). To change the mapping after appending, pass `discard_rows: true` to `set_import_mapping` and append again.
4. **`preview_import`** is a dry run. It reports how many people will be created and updated, how many existing values each property changes, how many people get opted out, the properties that will be created, and **rows that would merge two or more existing people into one**. A merge keeps one person and deletes the others, and cannot be undone. With `merges: "block"` (the default) a merge means no token; `"skip_rows"` leaves those rows out; `"allow"` merges them. When nothing blocks, it returns a `commit_token`, good for an hour.
5. Show the preview to the person you are working for. Then **`commit_import`** with the `commit_token`. It re-checks everything and refuses if the rows, the mapping or the people they match changed since the preview. People are created and updated over the next minutes.
6. **`get_import`** shows progress (`succeeded`, `failed`) and the reason for each failed row.

`cancel_import` deletes a draft and its staged rows. A committed import cannot be cancelled.

```json theme={null}
{
  "import_id": "<from create_import>",
  "mapping": {
    "Email": "email",
    "First name": "first_name",
    "Plan": { "property": "plan_tier" },
    "Region": { "new_property": { "key": "region", "name": "Region", "type": "string" } },
    "Marketing": "subscribed_to_emails",
    "Notes": "skip"
  }
}
```

## Data imports: list\_data\_imports

`list_data_imports` returns the org's import history (dashboard CSV imports and imports made with `create_import`), newest first, as `{ data_imports }`. Each import has `id`, `status`, `file_name`, `created_at`, `total_rows` and `processed_rows`. `processed_rows` counts successful and failed rows together; the tool does not split them. It takes no arguments and only reads history.

```json theme={null}
{}
```

## Common tasks

### Look up a person someone mentioned

<Steps>
  <Step title="Search">
    Call `search_people` with the email, phone or id you were given: `{ "query": "jane@acme.com" }`.
  </Step>

  <Step title="Disambiguate">
    If more than one person comes back, compare `primary_email`, `external_user_id` and `created_at`, or ask the person you are working for which one they mean.
  </Step>

  <Step title="Read the full record">
    Call `get_person` with the chosen `id`. Use `list_subscriptions` and `get_opt_outs` if the task is about billing or messaging.
  </Step>
</Steps>

### Add a new lead and make sure they can be emailed

<Steps>
  <Step title="Identify">
    Call `identify_person` with `{ "email": "lead@example.com", "first_name": "Sam" }`. Note `person_id` and whether `created` is `true`.
  </Step>

  <Step title="Check contact methods">
    Call `list_identifiers`. The email identifier should have `is_contact_method: true`. If it does not, call `update_identifier` with `{ "is_contact_method": true }`.
  </Step>

  <Step title="Check opt-outs">
    Call `get_opt_outs`. Do not change an existing opt-out without the person's consent.
  </Step>
</Steps>

### Tag everyone who did X with property Y

The people tools cannot query events. They can tag people who match person fields or a segment. If "did X" is an event, build a workflow that sets the property when the event happens (see [/mcp/workflows](/mcp/workflows)). If "did X" is visible on the person, do this:

<Steps>
  <Step title="Check or create the config">
    Call `list_property_configs`. If the key is missing, call `create_property_config`, for example `{ "key": "vip", "name": "VIP", "type": "boolean" }`.
  </Step>

  <Step title="Find the people">
    Call `list_people` with the matching filters (for example `{ "mrr_gte": 500, "limit": 200 }`), or `list_segment_members` with a segment's id. Note `total`.
  </Step>

  <Step title="Set the value on each person">
    For each person, call `set_person_property` with `{ "person_id": "...", "key": "vip", "value": true }`. Page with `offset` until you have covered `total`.
  </Step>

  <Step title="Verify">
    Spot-check a few people with `get_person_properties`. If you need a lasting group of everyone tagged, create a segment with `{ "custom_property_key": "vip", "op": "eq", "value": true }` and compare its `total` from `list_segment_members` with the number you tagged.
  </Step>
</Steps>

If the condition can be written as segment filters, consider whether you need the property at all. A segment stays current by itself; a property you stamp once goes stale.

### Build a segment of trial users who haven't activated

Segment filters only see person fields, so "activated" has to be on the person. If your product records it, it is usually a custom property such as `activated_at` (an ISO date string) or `activated` (a boolean).

<Steps>
  <Step title="Find the activation property">
    Call `list_property_configs` and look for the key your product uses. If none exists, ask the person you are working for how activation is recorded. Do not invent one: an empty property matches everyone.
  </Step>

  <Step title="Pick the right op for its type">
    For a string property such as `activated_at`, use `is_empty`, which matches people with no value. For a boolean property, `eq false` only matches people who have the value `false`, not people who were never stamped, so prefer a string property with `is_empty` when the property is only written on activation.
  </Step>

  <Step title="Create the segment">
    Call `create_segment`:

    ```json theme={null}
    {
      "name": "Trialing, not activated",
      "filters": [
        { "property": "subscription_status", "op": "eq", "value": "trialing" },
        { "custom_property_key": "activated_at", "op": "is_empty" }
      ]
    }
    ```
  </Step>

  <Step title="Verify membership">
    Call `list_segment_members` with the new `segment_id`. Spot-check two or three members with `get_person_properties`.
  </Step>
</Steps>

### Add a condition to an existing segment

<Steps>
  <Step title="Read the current filters">
    Call `get_segment` and copy its `filters` array.
  </Step>

  <Step title="Append the new condition">
    Add the new condition to the copied array, for example `{ "property": "created_at", "op": "gte", "value": "now-90d" }`.
  </Step>

  <Step title="Send the full list">
    Call `update_segment` with the complete array. Sending only the new condition would replace every existing one.
  </Step>
</Steps>

### Handle an unsubscribe request

<Steps>
  <Step title="Resolve the person">
    Call `search_people` with the address the request came from.
  </Step>

  <Step title="Opt out">
    Call `set_opt_outs` with `{ "person_id": "...", "email_opt_out": true }` (and `sms_opt_out: true` if they asked to stop texts too).
  </Step>

  <Step title="Confirm">
    Call `get_opt_outs` and report the result.
  </Step>
</Steps>

### Merge-safe correction of a wrong email

<Steps>
  <Step title="Add the correct email">
    Call `add_identifier` with `identifier_type: "email"`, `is_contact_method: true`, `is_primary_for_type: true`.
  </Step>

  <Step title="Read the result">
    If `has_conflict` is `true`, the address belongs to another person (`existing_org_user_id`). Stop and tell the person you are working for; they may be duplicates.
  </Step>

  <Step title="Remove the wrong one">
    Call `list_identifiers`, find the wrong email's `id`, and call `remove_identifier`.
  </Step>
</Steps>

## Mistakes to avoid

* **Looping `identify_person` over a file.** Use `create_import`: it checks the mapping, finds merges before they happen and reports per-row failures.
* **Committing an import without showing its preview.** `preview_import` is where merges and opt-outs show up. Get a go-ahead first.
* **Inventing ids.** Every `person_id`, `segment_id`, `config_id` and `identifier_id` comes from a list, search or get tool. A made-up UUID returns not found at best.
* **Guessing `external_user_id` in `identify_person`.** Pass it only when you read it from the customer's product. When in doubt, identify by email alone.
* **Using `search_people` to check existence before creating.** It searches substrings and caps at 50 results. Use `identify_person`, which is idempotent and merges duplicates.
* **Searching by name and concluding the person does not exist.** `search_people` matches identifier values, not the `first_name` / `last_name` fields. Try their email.
* **Calling `set_person_property` before the config exists.** It fails with not found. Call `list_property_configs`, then `create_property_config` if needed.
* **Sending the wrong type to `set_person_property`.** `"42"` is not a number and `"true"` is not a boolean. Types must match exactly.
* **Storing Flywheel's own data in custom properties.** Subscription status, MRR, LTV, payment dates and engagement dates are built-in fields you can already filter on. Copying them into custom properties creates stale duplicates and clutters every property picker in the org.
* **Storing a date you need to compare as a custom property and filtering it with `gt`/`lt`.** Custom string properties only allow `eq`, `neq`, `contains`, `in`, `is_empty` and `is_not_empty`. Range comparisons work on built-in date fields only.
* **Sending a partial filter list to `update_segment`.** It replaces the whole set. Read with `get_segment`, modify, send everything.
* **Using `eq false` on a boolean custom property to mean "not set".** People with no value do not match. Use a string property with `is_empty`, or confirm every person has the property.
* **Adding an email with `add_identifier` and expecting it to be mailable.** `is_contact_method` defaults to `false`.
* **Deleting a property config to "reset" it.** `delete_property_config` deletes every stored value too, and cannot be undone. Rename with `update_property_config` instead.
* **Treating `delete_person` as cleanup for unsubscribes.** Deleting a suppressed person is refused for a reason: it would make the address mailable again. Opt them out with `set_opt_outs` and keep them.
* **Opting someone back in without consent.** `set_opt_outs` with `false` should only follow an explicit request from that person.
* **Trying to edit subscriptions.** `list_subscriptions` is read-only; subscriptions change in Stripe, Paddle or Whop.

## Related pages

* [/mcp/overview](/mcp/overview): connecting to the MCP server and permissions.
* [/mcp/workflows](/mcp/workflows) and [/mcp/workflow-definitions](/mcp/workflow-definitions): reacting to events, including setting properties when something happens.
* [/mcp/broadcasts](/mcp/broadcasts): sending one email to a segment.
* [/mcp/webinars](/mcp/webinars): registering people for webinar sessions.
* [/mcp/signals](/mcp/signals): people worth acting on right now.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.