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

# The object model

> Every record in the CRM belongs to an object. Some objects ship with the product; others are created by the tenant. The API exposes both through the same

Every record in the CRM belongs to an **object**. Some objects ship with the
product; others are created by the tenant. The API exposes both through the same
two endpoints, so an integration can discover what exists and what shape it has
without anything being hard-coded.

```
GET /v1/objects                  what can I work with?
GET /v1/objects/{object}/schema  what fields does it have?
```

If you are building anything that has to survive a customer adding a field, start
here rather than hard-coding a field list.

***

## Objects

An object is either **built-in** or **custom**.

|            | Built-in                                                         | Custom                                   |
| ---------- | ---------------------------------------------------------------- | ---------------------------------------- |
| Name       | `leads`, `deals`, `customers`, `accounts`, `tasks`, `activities` | `object:<slug>`, e.g. `object:property`  |
| `kind`     | `builtin`                                                        | `custom`                                 |
| Defined by | The product                                                      | The tenant                               |
| Gated on   | `<module>:view`, e.g. `leads:view`                               | The object scope, e.g. `object:property` |

Custom objects are always prefixed with `object:`. That prefix is part of the
name — you pass it verbatim in the path, URL-encoded:

```
GET /v1/objects/object%3Aproperty/schema
```

### Listing objects

```bash theme={null}
curl -H "x-api-key: $KEY" https://<host>/functions/v1/public-api/v1/objects
```

```json theme={null}
{
  "success": true,
  "data": {
    "objects": [
      { "object": "leads", "label": "Lead", "plural": "Leads",
        "kind": "builtin", "module": "leads" },
      { "object": "object:property", "label": "Property", "plural": "Properties",
        "kind": "custom", "module": "object:property" }
    ]
  },
  "meta": { "request_id": "…" }
}
```

**The list is filtered by your key's scopes.** An object your key cannot view is
simply absent — it is not listed and marked inaccessible. This is deliberate: if
inaccessible objects were listed, the registry would tell you which objects a
tenant has configured, which is information a key without the matching scope is
not entitled to.

So `GET /v1/objects` answers *"what can this key work with"*, not *"what exists"*.
Two keys on the same tenant can legitimately get different lists.

***

## Schemas

```bash theme={null}
curl -H "x-api-key: $KEY" \
  https://<host>/functions/v1/public-api/v1/objects/leads/schema
```

```json theme={null}
{
  "success": true,
  "data": {
    "object": "leads",
    "label": "Lead",
    "plural": "Leads",
    "kind": "builtin",
    "module": "leads",
    "fields": [
      { "name": "name",  "label": "Name",  "type": "text",  "required": true,  "custom": false },
      { "name": "email", "label": "Email", "type": "email", "required": false, "custom": false },
      { "name": "state", "label": "State", "type": "select", "required": true, "custom": true,
        "options": [ { "label": "Draft", "value": "draft" } ] }
    ]
  }
}
```

### The field descriptor

| Field      | Meaning                                                              |
| ---------- | -------------------------------------------------------------------- |
| `name`     | The stable API name. Use this in requests. Never an internal id.     |
| `label`    | Human-readable, for display. Safe to show; do not key off it.        |
| `type`     | One of the types below.                                              |
| `required` | Whether a write must supply it.                                      |
| `custom`   | `true` if the tenant defined this field, `false` for built-ins.      |
| `options`  | Present on `select` fields only: `[{ label, value }]`. Send `value`. |

Types: `text`, `textarea`, `number`, `boolean`, `date`, `datetime`, `email`,
`phone`, `url`, `currency`, `select`.

That list is closed. An internal type we do not publish is reported as `text`
rather than passed through, so a UI-side change cannot alter your contract.

### Built-in and custom fields look identical

Internally this CRM has two different field systems with different column names.
You will never see that. Both are mapped to the descriptor above, and the only
way to tell them apart is the `custom` flag — which is there because you may want
to treat tenant fields differently in your UI, not because they behave
differently.

***

## What a tenant can change

Two things move under you. Both are why you should read the schema rather than
assume one.

**A built-in field can be turned off.** A disabled field is *absent* from the
schema. It is not returned as `enabled: false` — if it is not in `fields`, do not
send it.

**A built-in field can be made mandatory.** A field that is optional by default
comes back with `"required": true` for that tenant. Two tenants can therefore
return different `required` values for the same field, and neither is wrong.

Custom fields can be added and removed at any time.

### Name collisions

If a tenant creates a custom field whose name matches a built-in, **the built-in
wins** and the custom one is not published. Otherwise a custom `email` of type
`text` could shadow the real `email` field and the published type would stop
matching what writes actually accept.

***

## Errors

| Status | When                                                       |
| ------ | ---------------------------------------------------------- |
| `401`  | Missing or invalid key.                                    |
| `404`  | The object does not exist **or** your key may not view it. |
| `429`  | Rate limited.                                              |

The 404 is deliberately ambiguous. Distinguishing "no such object" from "not
allowed" would let a key probe for which objects a tenant has configured, which
is the same reason record fetches return 404 instead of 403.

If you get a 404 for an object you expect, check your key's scopes before
assuming the object is missing.

***

## Suggested integration flow

1. Call `GET /v1/objects` once at setup, and cache it.
2. Call `GET /v1/objects/{object}/schema` for each object you write to.
3. Build your field mapping from `name`, validating against `type`, `required`
   and `options`.
4. Re-fetch the schema periodically, or when a write fails validation — a tenant
   may have added a mandatory field since you last looked.

Do not hard-code field lists. A tenant adding one mandatory custom field is
enough to start failing every write from an integration that assumes a fixed
shape.

***

## Managing fields (provisioning)

```
POST  /v1/objects/{object}/fields
PATCH /v1/objects/{object}/fields/{field}
```

Requires the **`settings:configure`** scope — a key without it gets `403`. This
writes the tenant's real schema, so a field created here appears on the CRM's
forms immediately.

```bash theme={null}
curl -X POST -H "x-api-key: $KEY" -H "content-type: application/json"   -d '{"name":"deal_stage","label":"Deal Stage","type":"select","required":true,
       "options":[{"value":"new","label":"New"},{"value":"won","label":"Won"}]}'   ".../v1/objects/leads/fields"
```

* `name` is normalised to lowercase with underscores. Reserved names (`id`,
  `created_at`, `company_id`, …) and names that collide with an existing field
  are rejected.
* `label` is stripped of all markup before storage. A label renders on every form
  in the workspace, so it is never allowed to carry HTML.
* A `select` must supply `options`; any other type must not.

### Type and name are immutable

`PATCH` can change `label`, `required` and `options` — nothing else. Sending
`type` or `name` returns `422`, **even if the value matches what is already
stored**.

The reason is that values already exist. Flipping `text` to `number` converts
nothing; it just leaves stored values that no longer match the declared type, so
reads start failing for records nobody touched. If you need a different type,
create a new field.

### Retiring a field

There is no delete. Send `{"enabled": false}` instead:

```bash theme={null}
curl -X PATCH -H "x-api-key: $KEY" -H "content-type: application/json"   -d '{"enabled":false}' ".../v1/objects/leads/fields/deal_stage"
```

The field disappears from the schema — so it stops being published, stops being
accepted on writes, and stops appearing on forms — but every value already stored
against it is kept. Send `{"enabled": true}` to bring it back.

A hard delete would orphan that data with no way back, which is why the API does
not offer one. Fields on **custom objects** cannot currently be disabled this way
(there is no such flag on their storage); remove them in the CRM instead.

***

## Related

* [Working with records](/api-reference/records) — reading and writing records of these objects
* [Authentication and scopes](/api-reference/authentication) — what your key can see
* [Errors](/api-reference/errors) — the error envelope and codes
* [Rate limits](/api-reference/rate-limits)
