Skip to main content
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.

The people tools at a glance

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.
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. 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.
segment_id composes with every other filter, so “members of the Power Users segment who are past due” is one call:

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
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

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.

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), 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.
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.

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:
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.

remove_identifier

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

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.

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.

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.

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.

get_person_properties

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

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.

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.

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). 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:
Built-in fields and their ops: Custom property filters take their ops from the config’s type: 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.

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.
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.

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.

delete_segment

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

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.

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.

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.
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.

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.

Common tasks

Look up a person someone mentioned

1

Search

Call search_people with the email, phone or id you were given: { "query": "jane@acme.com" }.
2

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.
3

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.

Add a new lead and make sure they can be emailed

1

Identify

Call identify_person with { "email": "lead@example.com", "first_name": "Sam" }. Note person_id and whether created is true.
2

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 }.
3

Check opt-outs

Call get_opt_outs. Do not change an existing opt-out without the person’s consent.

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). If “did X” is visible on the person, do this:
1

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" }.
2

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.
3

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.
4

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.
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).
1

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.
2

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.
3

Create the segment

Call create_segment:
4

Verify membership

Call list_segment_members with the new segment_id. Spot-check two or three members with get_person_properties.

Add a condition to an existing segment

1

Read the current filters

Call get_segment and copy its filters array.
2

Append the new condition

Add the new condition to the copied array, for example { "property": "created_at", "op": "gte", "value": "now-90d" }.
3

Send the full list

Call update_segment with the complete array. Sending only the new condition would replace every existing one.

Handle an unsubscribe request

1

Resolve the person

Call search_people with the address the request came from.
2

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).
3

Confirm

Call get_opt_outs and report the result.

Merge-safe correction of a wrong email

1

Add the correct email

Call add_identifier with identifier_type: "email", is_contact_method: true, is_primary_for_type: true.
2

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.
3

Remove the wrong one

Call list_identifiers, find the wrong email’s id, and call remove_identifier.

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.