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.
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 typecontact_info: the primary and all contact methodsteam_members:{ primary_team_member, all_assigned }as user idscreated_at,updated_at
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 byexternal_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.
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_idwithdrop_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.
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:
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, displayname,type, optionaldescription. One per property. - A property value is one person’s value for one config.
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 withis_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.
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.
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.-
create_importwithname,columns(every column, as named in the rows) and up to 20sample_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 asuggestedtarget only when the header and the values agree) and lists every valid target, including the org’s existing properties. -
set_import_mappingwith 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) orunsubscribed_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".
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. - a person field:
-
append_import_rowswith 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, passdiscard_rows: truetoset_import_mappingand append again. -
preview_importis 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. Withmerges: "block"(the default) a merge means no token;"skip_rows"leaves those rows out;"allow"merges them. When nothing blocks, it returns acommit_token, good for an hour. -
Show the preview to the person you are working for. Then
commit_importwith thecommit_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. -
get_importshows 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.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 asactivated_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_personover a file. Usecreate_import: it checks the mapping, finds merges before they happen and reports per-row failures. - Committing an import without showing its preview.
preview_importis where merges and opt-outs show up. Get a go-ahead first. - Inventing ids. Every
person_id,segment_id,config_idandidentifier_idcomes from a list, search or get tool. A made-up UUID returns not found at best. - Guessing
external_user_idinidentify_person. Pass it only when you read it from the customer’s product. When in doubt, identify by email alone. - Using
search_peopleto check existence before creating. It searches substrings and caps at 50 results. Useidentify_person, which is idempotent and merges duplicates. - Searching by name and concluding the person does not exist.
search_peoplematches identifier values, not thefirst_name/last_namefields. Try their email. - Calling
set_person_propertybefore the config exists. It fails with not found. Calllist_property_configs, thencreate_property_configif 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 alloweq,neq,contains,in,is_emptyandis_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 withget_segment, modify, send everything. - Using
eq falseon a boolean custom property to mean “not set”. People with no value do not match. Use a string property withis_empty, or confirm every person has the property. - Adding an email with
add_identifierand expecting it to be mailable.is_contact_methoddefaults tofalse. - Deleting a property config to “reset” it.
delete_property_configdeletes every stored value too, and cannot be undone. Rename withupdate_property_configinstead. - Treating
delete_personas cleanup for unsubscribes. Deleting a suppressed person is refused for a reason: it would make the address mailable again. Opt them out withset_opt_outsand keep them. - Opting someone back in without consent.
set_opt_outswithfalseshould only follow an explicit request from that person. - Trying to edit subscriptions.
list_subscriptionsis read-only; subscriptions change in Stripe, Paddle or Whop.
Related pages
- /mcp/overview: connecting to the MCP server and permissions.
- /mcp/workflows and /mcp/workflow-definitions: reacting to events, including setting properties when something happens.
- /mcp/broadcasts: sending one email to a segment.
- /mcp/webinars: registering people for webinar sessions.
- /mcp/signals: people worth acting on right now.