Finding records

Last updated 17 August 2026

A rule set posted to the search endpoint, beside the count and page it returns

There are two ways to read a collection.

You want Send
everything, a page at a time GET /api/contacts
only the records matching a condition POST /api/contacts/search with a rule set

Filtering has its own endpoint, /search, and it takes the same rule sets the filters in the Flexie interface are built from. Anything you can ask for on screen, you can ask for here.

It applies to the entities that hold records: contacts, leads, accounts, deals, cases and your own custom entities. Tasks, emails and reports list and read the same way but take no filters, and users are a read-only listing.

Listing

curl -X GET "https://your-subdomain.flexie.io/api/contacts?limit=30&start=0" \
  -H "apikey: YOUR_API_KEY"
Parameter Does Default
limit how many records to return the account's page size
start how many to skip 0
orderBy the field to sort on date_modified
orderByDir ASC or DESC DESC

limit is capped at 100. Ask for 500 and you get 100, with the true total in the response so you know to keep going.

The shape of a filter

curl -X POST "https://your-subdomain.flexie.io/api/contacts/search" \
  -H "apikey: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "filters": {
      "condition": "AND",
      "rules": [
        { "field": "email", "operator": "contains", "value": "acme.com" }
      ]
    },
    "orderBy": "date_added",
    "orderByDir": "DESC",
    "page": 1,
    "limit": 50
  }'

A rule has three parts:

Key Holds
field the field's alias, or an object for a field on a related record
operator what to compare, from the tables below
value what to compare against. Some operators take none

And the response tells you where you are:

{ "total": "4", "current_page": 1, "total_pages": 1, "contacts": [] }

Which fields, and which operators

You do not have to guess either. Ask the entity what it can be filtered by, and it answers with everything: its own fields, the ones Flexie maintains such as date_added, the ids of records it points at, and the fields of every record it is related to.

curl "https://your-subdomain.flexie.io/api/contacts/list/filters" \
  -H "apikey: YOUR_API_KEY"

Each entry gives exactly what to send as field, the operators it accepts, and the shape of value each one wants, and each says which group it belongs to so you can present them the way the interface does.

Use this one for filtering. There is a second endpoint, list/fields, which returns the fields somebody configured on the entity and nothing else:

curl "https://your-subdomain.flexie.io/api/contacts/list/fields" -H "apikey: YOUR_API_KEY"

Both answer in the same shape, so one reader handles either. The difference is scope: list/fields is your own fields, list/filters is everything a rule may name, which also takes in the fields Flexie maintains, the ids of related records, and the fields on the other side of a relationship. A field that is configured but cannot be filtered comes back with an empty operators list, and a rule naming it is refused.

{
  "total": 168,
  "fields": [
    {
      "field": "date_added",
      "label": "Date Added",
      "type": "datetime",
      "group": "System Fields",
      "related": false,
      "operators": [
        { "operator": "is",       "value": "period" },
        { "operator": "between",  "value": "range"  },
        { "operator": "greater",  "value": "single" },
        { "operator": "is_empty", "value": "none"   }
      ]
    }
  ]
}

field is the name to put in a rule. Beside each operator is the shape of value it expects, and that word is a description rather than a value: { "operator": "is", "value": "period" } means is wants a period, not that you should send the word period.

Shape What to send
single One value.
{ "field": "email", "operator": "contains", "value": "acme" }
range Two bounds in an array, the low one first.
{ "field": "amount", "operator": "between", "value": [1000, 5000] }
period A named stretch of time, so you never work out a date yourself.
{ "field": "date_added", "operator": "is", "value": "this_year" }
offset A sign, a number and a unit, as three strings.
{ "field": "date_added", "operator": "less_than_now", "value": ["-", "7", "days"] }
list An array of values, any of which match.
{ "field": "owner_id", "operator": "in", "value": [1, 6] }
polygon The points of an area. Only on a map field, where the area is drawn rather than typed.
none Nothing at all, leave value out.
{ "field": "phone", "operator": "is_empty" }

Every period and every offset is listed further down, under Periods and Relative to right now.

Read the field list once at the start of an integration and you know how to ask about anything on the entity, including fields somebody added after you wrote the code.

The fields Flexie maintains

Alongside your own fields, every record carries a set Flexie keeps up to date, and they answer most of the questions worth asking. They appear in the filter list under System Fields, and they are not in list/fields because nobody configured them:

Field Holds
date_added, date_modified when the record appeared and when it last changed
email_count, last_email_date emails sent, and the last one
marketing_email_count, last_marketing_email_date the same for marketing email
last_email_click_date the last time they clicked something
sms_count, last_sms_date messages sent, and the last one
task_count, last_task_date tasks logged, and the last one
_next_upcoming_task_date the next task due, if any
note_count, last_note_date notes written, and the last one
last_hit_date the last visit to your site

Contacts add deal_count, last_deal_date, deal_ltv and converted_date.

The counts are numbers and the dates are datetimes, so they take the operators of those types. "Never emailed" is last_email_date with is_empty, and "quiet for three months" is the same field with is_not and last_90_days.

Every operator

Which operators a rule may use depends on the field's type. This is the whole set:

Field type Operators
text, url equal, not_equal, contains, not_contains, begins_with, not_begins_with, ends_with, not_ends_with, is_empty, is_not_empty
tags, multiselect contains, not_contains, begins_with, not_begins_with, ends_with, not_ends_with, is_empty, is_not_empty
number equal, not_equal, greater, greater_or_equal, less, less_or_equal, between, not_between, is_empty, is_not_empty
boolean, select, country, region, timezone, state equal, not_equal, is_empty, is_not_empty
multi-value select in, not_in
datetime is, is_not, equal, not_equal, greater, greater_or_equal, greater_than_now, less, less_or_equal, less_than_now, between, not_between, is_empty, is_not_empty
date the same, with greater_than_today and less_than_today in place of the _now pair
time equal, not_equal, greater, greater_or_equal, less, less_or_equal, between, not_between, is_empty, is_not_empty
reference equal, not_equal, is_empty, is_not_empty
point in_polygon, is_empty, is_not_empty
date range includes, excludes, is_empty, is_not_empty
list membership in_list, not_in_list
subscription is_subscribed, is_not_subscribed, on the __unsubscribes field

An operator the field does not accept is refused with a 400, not ignored.

Text

Everyone at a domain:

{ "field": "email", "operator": "contains", "value": "acme.com" }

Only addresses that end in it, which is the stricter question:

{ "field": "email", "operator": "ends_with", "value": "@acme.com" }

A reference that starts with a prefix:

{ "field": "customer_code", "operator": "begins_with", "value": "AC-2026" }

An exact match, and its opposite:

{ "field": "status", "operator": "equal", "value": "Qualified" }
{ "field": "status", "operator": "not_equal", "value": "Qualified" }

Records missing something, and records that have it:

{ "field": "phone", "operator": "is_empty" }
{ "field": "phone", "operator": "is_not_empty" }

is_empty and is_not_empty take no value at all. Note that not_equal and not_contains match only records that have a value, so pair them with is_empty in an OR group when you want the blanks too.

Numbers

{ "field": "deal_count", "operator": "greater", "value": 0 }
{ "field": "annual_revenue", "operator": "greater_or_equal", "value": 50000 }
{ "field": "points", "operator": "between", "value": [100, 500] }
{ "field": "points", "operator": "not_between", "value": [100, 500] }

between and not_between take their two bounds as an array, low first.

Dates

A fixed range, both bounds together:

{ "field": "date_added", "operator": "between",
  "value": ["2026-01-01 00:00:00", "2026-06-30 23:59:59"] }

Everything since a moment, or everything before one:

{ "field": "date_added", "operator": "greater", "value": "2026-01-01 00:00:00" }
{ "field": "date_added", "operator": "less", "value": "2026-01-01 00:00:00" }

Periods, so you never calculate a boundary

is and is_not take a period instead of a date, and it is worked out at the moment the request runs. A saved query stays correct as time passes:

{ "field": "date_added", "operator": "is", "value": "this_month" }
{ "field": "date_added", "operator": "is_not", "value": "this_year" }

The periods are:

Group Values
days today, yesterday
calendar this_week, this_month, this_quarter, this_year
the one before last_week, last_month, last_quarter, last_year
the one ahead next_week, next_month, next_quarter, next_year, next_day
rolling back last_hour, last_12_hours, last_24_hours, last_7_days, last_14_days, last_30_days, last_60_days, last_90_days
rolling forward next_hour, next_12_hours, next_24_hours, next_7_days, next_14_days, next_30_days, next_60_days, next_90_days
special birthday_today

Relative to right now

greater_than_now and less_than_now take an offset, written as three parts: a sign, a number and a unit.

Anything due in the next three days:

{ "field": "due_date", "operator": "less_than_now", "value": ["+", "3", "days"] }

Anything untouched for a fortnight:

{ "field": "date_modified", "operator": "less_than_now", "value": ["-", "14", "days"] }

On a date field the two are called greater_than_today and less_than_today, and they compare whole days.

Yes and no

A boolean is matched with the words, not with true and false:

{ "field": "is_reseller", "operator": "equal", "value": "yes" }
{ "field": "is_reseller", "operator": "equal", "value": "no" }

A record where the box was never touched is neither, so it answers to is_empty.

References

Every record carries the ids of the records it points at, and they come back in the response alongside everything else:

{ "id": "8873", "account_id": "354", "owner_id": "9", "subscriptions_id": null }

So when you already hold an id, filter straight on it. Everything belonging to one account:

{ "field": "account_id", "operator": "equal", "value": 354 }

Everything not yet attached to anything, which is the usual way to find orphans to clean up:

{ "field": "account_id", "operator": "is_empty" }

A reference rule has to sit in an AND group. Inside an OR it is refused rather than quietly matching something else.

You can filter on a field of the record at the other end of a relationship, which is how you ask for "contacts whose account is called something" without fetching accounts first:

{ "field": { "entity": "account", "field": "name" }, "operator": "contains", "value": "acme" }

The join is worked out for you.

Which relationships exist depends on how your workspace is set up, so the entity publishes them:

curl "https://your-subdomain.flexie.io/api/contacts/list/filters" \
  -H "apikey: YOUR_API_KEY"

Entries with "related": true sit at the other end of a relationship, including your own custom entities. Their field is an object rather than a name:

{
  "label": "Subscription / Status",
  "related": true,
  "field": {
    "entity": "subscriptions",
    "relation": "manyToOne",
    "through": "subscriptions_id",
    "field": "status"
  }
}

Put that object into a rule as it is:

{
  "field": { "entity": "subscriptions", "field": "status" },
  "operator": "equal",
  "value": "past_due"
}

entity and field identify it. Where an entity can be reached by more than one route, add through to say which. The join is worked out for you, and the rule looks the same whether the relationship is one-to-many or many-to-many. Examples has this in both directions, including between two custom entities.

Email subscription

Whether someone may be emailed is not a field on the record. It is whether an unsubscribe exists for their address, so it is asked with its own field, __unsubscribes, and it takes no value:

{ "field": "__unsubscribes", "operator": "is_subscribed" }
{ "field": "__unsubscribes", "operator": "is_not_subscribed" }

Every record is one or the other, so the two add up to the whole set. It matches on the address, which means a contact and a lead sharing an address are both opted out, and a record with no address is counted as subscribed because there is nothing to have unsubscribed.

Combine it like any other rule. People who have opted out of email but left you a phone number:

{
  "condition": "AND",
  "rules": [
    { "field": "__unsubscribes", "operator": "is_not_subscribed" },
    { "field": "phone", "operator": "is_not_empty" }
  ]
}

Combining conditions

Any rule can be a group instead: give it its own condition and rules, and groups nest as deep as you need.

Contacts added this year that are either at your domain or have no address at all:

{
  "condition": "AND",
  "rules": [
    { "field": "date_added", "operator": "is", "value": "this_year" },
    {
      "condition": "OR",
      "rules": [
        { "field": "email", "operator": "contains", "value": "acme.com" },
        { "field": "email", "operator": "is_empty" }
      ]
    }
  ]
}

A worked example putting most of it together. Contacts at one account, with at least one deal, touched in the last month, newest first:

curl -X POST "https://your-subdomain.flexie.io/api/contacts/search" \
  -H "apikey: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "filters": {
      "condition": "AND",
      "rules": [
        { "field": "account_id", "operator": "equal", "value": 354 },
        { "field": "deal_count", "operator": "greater", "value": 0 },
        { "field": "date_modified", "operator": "is", "value": "last_30_days" }
      ]
    },
    "orderBy": "date_modified",
    "orderByDir": "DESC",
    "page": 1,
    "limit": 100
  }'

Paging

page and limit walk a filtered result, and total counts the whole set rather than the page:

{ "filters": {}, "page": 1, "limit": 100 }
{ "filters": {}, "page": 2, "limit": 100 }

total_pages in the response tells you when to stop. Send an orderBy whenever you page, because without one nothing guarantees the same order between requests, so a record can appear twice or be skipped.

Custom records

A custom entity filters exactly the same way. The path carries its table name exactly as it is set in Flexie, lower case with underscores:

curl -X POST "https://your-subdomain.flexie.io/api/ce/projects/search" \
  -H "apikey: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "filters": {
      "condition": "AND",
      "rules": [
        { "field": "project_code", "operator": "begins_with", "value": "AC-2026" },
        { "field": "closed_date", "operator": "is_empty" }
      ]
    },
    "limit": 30
  }'

When a filter is rejected

A rule the entity cannot answer is refused with 400 and Invalid filters:

{ "error": { "code": 400, "message": "Invalid filters" } }

This is deliberate. The alternative is to ignore the rule and reply with an unfiltered list and a 200, which looks exactly like a filter that matched everything. The causes:

  • a field that is not one of the entity's filterable fields. id is one of these, so filter on a field that identifies the record instead
  • an operator that field's type does not accept
  • a reference rule inside an OR group
  • a malformed rule set, such as a group with no rules

A 400 means the filter never ran. It is not a partial result.

The older filter format

POST /api/contacts, the collection URL itself, accepts an older filters array. It still works and existing integrations need no change:

{
  "filters": [
    {
      "type": "text",
      "alias": "email",
      "value": { "operator": "like", "input": "acme.com" },
      "strict": false, "starts": false, "ends": false
    }
  ],
  "start": 0,
  "limit": 30
}

Its operators are eq, neq, gt, gte, lt, lte, like, notLike, in, notIn, isNull and isNotNull, and text matching is controlled by three booleans rather than by the operator name. like on its own is an exact match. strict: false with starts and ends both false matches anywhere in the field, starts: true matches the beginning and ends: true matches the end. Leave starts and ends out and they behave as true, which quietly turns "anywhere" into "begins with".

It has no groups, no periods and no way to reach a related record, so use /search for anything new.