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

# CRM API

> Configure and run the OrbitForms CRM from code or an AI agent: schema, settings, custom fields, pipelines, companies, deals, tasks, notes, logged activity, timelines, files and saved views.

Everything you can configure in **Settings → CRM** and every record on the Companies, Deals and Tasks pages is available here. The same endpoints power the [MCP server](https://docs.useorbitcrm.ai/mcp/tools.md), so an agent can set up a CRM end to end: learn the model, add fields, build pipelines, then create and move deals.

> [!NOTE]
>
> **Start with the schema.** `GET /api/v1/crm/schema` returns every object's standard and custom fields, the pipelines and their stages, all settings (with a description of each), tags, and team members (the IDs you use for `owner_user_id` and `assignee_user_id`).

## Scopes

| Scope | Allows |
| - | - |
| `crm:read` / `crm:write` | Read / change settings, custom fields, pipelines and stages, and saved contact views |
| `companies:read` / `companies:write` / `companies:delete` | Companies, their parent and child companies, and their tags |
| `deals:read` / `deals:write` / `deals:delete` | Deals, the contacts linked to them and their tags (`deals:read` can also list pipelines) |
| `tasks:read` / `tasks:write` | Tasks (write includes completing and deleting) |
| `notes:read` / `notes:write` | Notes on contacts, companies and deals, and comments on notes and logged activity |
| `activities:read` / `activities:write` | Timelines, logged emails, calls, meetings and messages, and activity imports (write includes undoing an import) |
| `files:read` / `files:write` | Files on contacts, companies and deals (read includes download links) |
| `contacts:read` / `contacts:write` | Contacts, the tag library (`/api/v1/contact-tags`, shared by contacts, companies and deals), and linking a contact to a company or owner |

Create a key with the **CRM agent** preset in [Settings → API keys](https://app.useorbitcrm.ai/settings/api-keys) to get all of these except deletes. The `:delete` scopes can be granted to API keys and to OAuth apps registered in the Developer Portal; assistants that connect over [MCP](https://docs.useorbitcrm.ai/mcp/overview.md) never hold them.

### Roles and record access

A credential acts as the person who owns it, within their **current** role and record access, on every request. Scopes say what a credential may ask for; the owner's role says what they may do:

* **Role permissions.** Each role (Admin, Editor, Viewer, or a custom role from Settings → Roles) allows, per object (contacts, companies, deals, tasks), viewing, creating, editing, deleting, bulk edits, importing and exporting. A write the role doesn't allow answers `403` with `error: "forbidden"`, a sentence in `error_description` and the missing `permission` (for example `"contacts.delete"`), whatever the scope. A role without View for an object sees none of its records (lists are empty, single records `404`).
* **Record access.** A member may be limited to the records of their teams or their own (Settings → Members). Managers also see, and with Edit change, the records of the people who report to them, directly or through others. Emails have their own scope, by who is on the email (sender, recipient, cc) rather than by record owner, and a member may keep their own email private; see [Contact Emails](https://docs.useorbitcrm.ai/developers/api/emails.md).

## Configuration

| Method | Endpoint | Description |
| - | - | - |
| `GET` | `/api/v1/crm/schema` | The whole CRM model in one call |
| `GET` | `/api/v1/crm/settings` | Settings for contacts, companies, deals and tasks |
| `GET` | `/api/v1/crm/settings/:object` | Settings for one object (`contact`, `company`, `deal`, `task`) |
| `PATCH` | `/api/v1/crm/settings/:object` | Change settings; keys you leave out keep their values, unknown keys are rejected |
| `GET` | `/api/v1/crm/fields?object=` | Custom fields for `contact`, `company`, `deal` or `task`, in display order |
| `POST` | `/api/v1/crm/fields` | Add a custom field (`group_id` puts it in a group) |
| `PATCH` | `/api/v1/crm/fields/:object/:field_name` | Change a field's label, options, description, `group_id`, `position`, or its `field_type` when no stored value is lost (see [Changing a field's type](#changing-a-fields-type)) |
| `DELETE` | `/api/v1/crm/fields/:object/:field_name` | Remove a field (stored values are kept) |
| `GET` / `POST` | `/api/v1/crm/field-groups` | An object's field groups in order (`?object=`) / add one: `{ "object", "label" }` |
| `PATCH` / `DELETE` | `/api/v1/crm/field-groups/:id` | Rename or move a group (`label`, `position`) / delete it (its fields stay, ungrouped) |
| `GET` | `/api/v1/crm/layouts` | Record page layouts: each object's cards in order with their fields, and which are hidden under All fields (`?object=` for one). Read-only; admins edit them in Settings (see [Record layouts](#record-layouts)) |
| `GET` | `/api/v1/pipelines` | Pipelines with ordered stages |
| `POST` | `/api/v1/pipelines` | Create a pipeline (optionally with stages) |
| `GET` / `PATCH` / `DELETE` | `/api/v1/pipelines/:id` | Read, rename / make default / set its [rules](#pipeline-rules-and-required-fields) (`block_skip`, `block_backward`, `lock_closed`; admins), delete (no deals allowed, including deals in Recently deleted: restore and move them, or delete them permanently, first) |
| `POST` | `/api/v1/pipelines/:id/stages` | Add a stage |
| `PUT` | `/api/v1/pipelines/:id/stages` | Reorder stages: `{ "stage_ids": [...] }` |
| `PATCH` / `DELETE` | `/api/v1/pipelines/:id/stages/:stageId` | Change or delete a stage (`?move_deals_to=` when it has deals). `required_fields` sets what a deal needs to enter it (admins). Changing a stage's `kind` or passing `move_deals_to` also moves or closes every deal in it, so those two calls need `deals:write` as well as `crm:write` (`403 insufficient_scope` otherwise) |
| `GET` | `/api/v1/views?object=` | Saved views of the `contacts`, `companies`, `deals` or `tasks` list (the shared ones and your private ones), plus `filter_fields`: every field its filters take, with operators and allowed values, your custom fields included |
| `POST` | `/api/v1/views` | Save a view: `{ object, name, visibility?: "shared" \| "private", config? }` |
| `GET` / `PATCH` / `DELETE` | `/api/v1/views/:id` | Read, change (`name`, `visibility`, `config`, `position`, `folder_id`) or delete a view. The `config` keys you send (`columns`, `pinned`, `filters`, `sort`) replace the stored ones and the rest stay; `"sort": null` clears the sort |
| `GET` | `/api/v1/folders?kind=&object=` | Folders for `workflow`, `sequence` or `view` (views: one list's folders, `object=contacts`, `companies`, `deals` or `tasks`): `id`, `name`, `parent_id` (folders go one level deep), `position`. File a workflow, sequence or view with `folder_id` on its own create or update; `null` takes it out of its folder. A shared view is filed by editors and admins, a private one by its creator |
| `GET` | `/api/v1/views/:id/records` | Apply a view: the records it holds now, in its order (`page`, `per_page`, `search`, `pipeline_id` for deal views, `tz`) |
| `GET` / `POST` | `/api/v1/contact-views` | Saved views of the Contacts table (the same views as `object=contacts`) |
| `PATCH` / `DELETE` | `/api/v1/contact-views/:id` | Change (config keys merge the same way) or delete a contact view |
| `GET` / `POST` | `/api/v1/lists` | [Static lists](#static-lists) of contacts or companies (`?object=`), with `member_count` / create one: `{ object, name, description? }` |
| `GET` / `PATCH` / `DELETE` | `/api/v1/lists/:id` | Read, rename (`name`, `description`) or delete a list. Deleting keeps its records |
| `GET` | `/api/v1/lists/:id/members` | The list's contacts or companies, a page at a time (`page`, `per_page`, `search`), as you would see them in Orbit |
| `POST` / `DELETE` | `/api/v1/lists/:id/members` | Add or remove records: `{ "ids": [...] }`, 1 to 10,000 contact or company ids |

### Saved views

A view is the layout of a list: `config.columns` (in order), `config.pinned`, `config.sort` (`{ key, direction }`) and `config.filters`, groups of conditions joined by `and` / `or` (`[{ "filters": [{ "field", "operator", "value" }], "innerConjunction": "and", "conjunction": "and" }]`). A view is `shared` with the workspace (editors and admins create them; admins and the person who made one change it) or `private` to the person who made it (anyone can keep up to 50 per list; only they see it, and only they can share it). Reading a view needs `crm:read` or the list's own read scope (`contacts:read`, `companies:read`, `deals:read`, `tasks:read`); changing one needs `crm:write` or the list's write scope.

| List | Filter fields |
| - | - |
| `companies` | `name`, `domain`, `industry`, `phone`, `location`, `description` (text); `employeeCount`, `annualRevenue`, `contactCount`, `openDeals`, `openValue` (number); `createdAt`, `updatedAt` (date, `YYYY-MM-DD`); `owner` (a user id); `tag`; the activity rollups `lastContactedAt`, `lastActivityAt`, `nextActivityAt` (dates) and `emailsSent`, `callCount`, `meetingCount` (numbers); `cf:<field_name>`; `deal.<deal field>` |
| `deals` | `name`, `currency`, `lostReason`, `description` (text); `amount` (number); `closeDate`, `createdAt`, `updatedAt`, `stageChangedAt`, `closedAt` (date); `status` (`open`, `won`, `lost`); `stage` (stage id); `pipeline` (pipeline id); `owner` (a user id); `tag`; the activity rollups `lastContactedAt`, `lastActivityAt`, `nextActivityAt` (dates) and `emailsSent`, `callCount`, `meetingCount` (numbers); `cf:<field_name>`; `company.<company field>` |
| `contacts` | The Contacts fields, plus `company.<company field>` |
| `tasks` | `title`, `description` (text); `status` (`open`, `done`); `type` (a task type key from `GET /api/v1/task-types`); `priority` (`high`, `normal`, `low`); `assignee` (a user id; `is_empty` is unassigned); `repeat` (`day`, `week`, `month`; `is_empty` is a task that doesn't repeat); `dueAt` (date; also `within_next`, `within_last` and `more_than_ago` with a number of days, counted in calendar days in the `tz` you pass: `within_next` `7` is today and the six days after, `within_last` `7` the seven days before today and today up to now, `more_than_ago` `0` before today); `overdue` (`equals` `true` or `false`: open and due before today began in the `tz` you pass); `completedAt`, `createdAt`, `updatedAt` (dates, also `within_last` and `more_than_ago`); `cf:<field_name>` (task custom fields); `contact.<contact field>`, `company.<company field>`, `deal.<deal field>` |

Task view columns are `name` (the title), `status`, `type`, `due`, `priority`, `assignee`, `contact`, `company`, `deal`, `repeat`, `description`, `completed_at`, `created_at`, `updated_at` and `cf:<field_name>`; every column but `assignee`, `repeat` and `description` sorts. A task view has no status filter unless you add one: Orbit's own **Open tasks** view is `"status" equals "open"`, soonest due first.

Custom fields filter by type: number, currency and percent as numbers; date and date-time by day (`YYYY-MM-DD`, in the `tz` you pass); Yes / No, dropdowns and teammates by value; multi-selects by their items (`equals` is "includes", `not_equals` "does not include"); every other type as text. `GET /api/v1/views?object=` lists the exact keys for your workspace.

`company.<field>` filters on a contact's company or on any of a deal's companies (its primary company and the others associated with it), and `deal.<field>` on any deal a company is on; on tasks, `contact.<field>`, `company.<field>` and `deal.<field>` filter on the task's own contact, company and deal (`is_empty` matches a task without one): `"deal.status" equals "won"` is a company with a won deal, and `not_equals` / `is_empty` mean none of the linked records match (`"deal.status" not_equals "won"` is a company without a won deal). Linked records you can't see never match, and a linked company's counts and totals (`company.contactCount`, `company.openDeals`, `company.openValue`) count only the contacts and deals you can see. A filter on a linked list needs your token to read that list too (`contact.*`: `crm:read` or `contacts:read`; `company.*`: `crm:read` or `companies:read`; `deal.*`: `crm:read` or `deals:read`): saving, changing or applying a view with one answers `403 insufficient_scope` otherwise, and `filter_fields` leaves those fields out. `GET /api/v1/views/:id/records` returns exactly what you would see in the list in Orbit: your record access, pipeline visibility and Recently deleted apply.

```bash theme={null}
curl -X POST https://app.useorbitcrm.ai/api/v1/views \
  -H "Authorization: Bearer $ORBIT_API_KEY" -H "Content-Type: application/json" \
  -d '{ "object": "companies", "name": "SaaS with open deals", "visibility": "private",
        "config": { "filters": [{ "filters": [
          { "field": "industry", "operator": "equals", "value": "SaaS" },
          { "field": "deal.status", "operator": "equals", "value": "open" } ],
          "innerConjunction": "and", "conjunction": "and" }],
          "sort": { "key": "openValue", "direction": "desc" } } }'
```

### Static lists

A list holds contacts or companies that you put in it: by hand from the Contacts and Companies tables, from a CSV import, or through `POST /api/v1/lists/:id/members`. Unlike a saved view it doesn't change on its own; a record stays in it until someone takes it out (or the record is deleted; a merged record's lists move to the record that's kept). Lists are shared with the workspace: editors and admins create, rename and fill them, and admins or the person who made a list delete it. A workspace has at most 1,000 lists of each kind, with unique names.

Reading lists needs `crm:read` or the records' read scope (`contacts:read`, `companies:read`); changing them needs `crm:write` or the records' write scope. `member_count` and the members you read count only the records your record access lets you see. Adding answers `{ added, already, skipped, skipped_ids }` and removing `{ removed, skipped }`: records that don't exist, are in Recently deleted or are outside your record access are skipped (up to 100 of their ids come back).

Any filter on contacts or companies can use a list: `{ "field": "list", "operator": "equals", "value": "<list id or name>" }` is "is in the list", `not_equals` "is not in it", `is_not_empty` "is in any list" and `is_empty` "is in no list" (`company.list` filters a contact or deal on its companies' lists). So a saved view can be "in the Webinar list and in the US", and a list itself works in these places: the Contacts and Companies tables, exports, the Dialer's call lists (`GET /api/v1/dialer/call-lists/preview?list_id=`), a sequence's audience, the **Added to list** workflow trigger and API filters. Schedules (and anything else that takes only a saved view) use a saved view filtered by the list.

```bash theme={null}
curl -X POST https://app.useorbitcrm.ai/api/v1/lists/$LIST_ID/members \
  -H "Authorization: Bearer $ORBIT_API_KEY" -H "Content-Type: application/json" \
  -d '{ "ids": ["8b2d...", "f01c..."] }'
```

### Settings

| Object | Setting | Type | What it does |
| - | - | - | - |
| `contact` | `auto_link_company_by_domain` | boolean | Link new contacts to the company whose domain matches their email |
| `contact` | `sync_owner_to_company` | `fill_empty` \| `always` \| `off` | Give a contact's company the contact's owner: only while the company has none (default), whenever they differ, or never. Applies from every source |
| `company` | `auto_create_from_contacts` | boolean | Create a company when a new contact names one that doesn't exist |
| `company` | `auto_create_from_email_domain` | boolean | Create a company from a new contact's work email domain when they name none and no company has that domain (personal addresses like gmail.com are skipped) |
| `contact`, `company`, `deal` | `required_on_create` | string\[] | Fields a new record must have when people, the API or MCP create it: built-in keys or `cf:<field_name>` (for deals also `contacts`, a linked contact). Admins only; see [Pipeline rules and required fields](#pipeline-rules-and-required-fields). While companies have required fields, none are created automatically from contacts |
| `deal` | `default_currency` | string | The workspace currency (ISO 4217): new deals use it, and every deal's `amount_home` is in it. Changing it re-bases the exchange rates; see [Currencies and exchange rates](#currencies-and-exchange-rates) |
| `deal` | `lost_reasons` | string\[] | Reasons offered when a deal is marked lost |
| `deal` | `require_lost_reason` | boolean | Require `lost_reason` before a deal moves to a lost stage |
| `deal` | `stale_after_days` | number \| null | Flag open deals with no stage change in this many days |
| `deal` | `default_owner` | `record_owner` \| `creator` \| `none` | Owner of a deal created without one: the primary contact's owner, else the company's, else the creator (default); the creator; or no one. Set once at creation |
| `deal` | `follow_contact_owner` | boolean | When a contact's owner changes, move their open primary-contact deals that still belonged to the previous owner to the new owner. Won and lost deals never change (default off) |
| `deal` | `card_fields` | string\[] | Fields shown on deal board cards under the name, in order, at most 5: `amount`, `close_date`, `owner`, `company`, `primary_contact`, `stage_age`, `created_at`, `updated_at`, `last_contacted`, `next_activity`, or `cf:<field_name>` for a custom deal field. Default `["owner", "company", "amount", "close_date"]`; `[]` shows just the name |
| `deal` | `list_columns` | string\[] | Columns of the deals list after the name, in order, at most 25: the card keys plus `stage`, `last_activity_date`, `emails_sent`, `calls`, `meetings`, `days_to_close`, or `cf:<field_name>`. Default `["stage", "amount", "close_date", "owner", "company", "stage_age"]` |
| `deal` | `fiscal_year_start_month` | number | The month (1-12) the fiscal year starts in: reports, goals and the forecast use it for quarters and years |
| `deal` | `team_performance_visibility` | `everyone` \| `admins` | Who sees teammates' goals, attainment and forecasts: everyone, for the people whose deals their record access shows (default), or admins only (others see their own). Only admins change it, whatever the path |
| `deal` | `deal_splits` | boolean | [Deal splits](#deal-splits): a deal's credit shared by percentage, followed by reports and Revenue won goals (default `false`). Only admins change it, whatever the path |
| `task` | `default_due_days` | number \| null | Due date for new tasks, in days |
| `task` | `default_assignee` | `creator` \| `record_owner` \| `none` | Who gets a task when no one is picked |

### Custom fields

Contacts, companies, deals and tasks take every type. `field_name` defaults to a snake\_case version of the label and never changes. Records store values in `custom_fields`, keyed by `field_name`, and a value for a defined field is checked against its type and stored in the shape below (the first six types are the ones the API has always had; their values haven't changed):

| `field_type` | Value in `custom_fields` | Also accepted on writes |
| - | - | - |
| `string` | Text, up to 2,000 characters | Numbers and `true`/`false`, as text |
| `number` | A number | `"1,200"` |
| `boolean` | `true` / `false` | `"yes"`, `"no"`, `"y"`, `"n"`, `1`, `0` |
| `date` | `"YYYY-MM-DD"` | `"M/D/YYYY"`, or a date-time (its day) |
| `url` | `"https://..."` | A domain without a scheme (`https://` is added) |
| `select` | One of `options` | Any case (`"emea"` matches `"EMEA"`) |
| `long_text` | Text, up to 10,000 characters | |
| `currency` | A number, in the workspace currency (`default_currency` in deal settings) | `"$1,200.50"`, `"USD 99"`. Commas only between thousands: `"1.200,50"` is refused |
| `percent` | A number: `12.5` means 12.5% | `"12.5%"`. `"12,5"` is refused |
| `datetime` | An ISO 8601 instant in UTC, `"2026-10-29T14:30:00.000Z"` | Any offset (`+02:00`), no zone (read as UTC), a plain date (its midnight UTC), epoch milliseconds |
| `email` | A lowercase email address | `"mailto:..."` |
| `phone` | E.164 (`"+12025550123"`) when it parses in the team's default country (Settings > Dialer), else the text | |
| `multi_select` | A list of `options`, in the field's option order | One option, or text separated by `;` (or `,`) |
| `user` | A workspace member's user ID | The member's email or full name |

`select` and `multi_select` fields need `options` (up to 100, matched case-insensitively). A record keeps an option removed from the field since (and a `user` field keeps someone who has left) while the value is sent back unchanged, so you can update the rest of the record, or the rest of a list. `null`, `""` or `[]` removes a value. CSV exports write `user` fields as the member's email. `GET /api/v1/crm/schema` lists the types per object with each one's JSON shape (`custom_field_type_reference`).

Fields come back in display order: the ones in no group first, then each group's, the way record pages show them. Each has `position` (its place in that order) and `group_id` (`null` for none); the groups are in `GET /api/v1/crm/field-groups` and in the schema's `field_groups`. Move a field with `PATCH ... { "position": 0 }` or `{ "group_id": "..." }`.

#### Changing a field's type

`PATCH /api/v1/crm/fields/:object/:field_name { "field_type": "..." }` changes the type only when no stored value is lost, converting the stored values where their shape changes:

| From | To | Stored values |
| - | - | - |
| `string` | `long_text` | Unchanged |
| `long_text` | `string` | Unchanged; refused while any value is longer than 2,000 characters |
| `url`, `email`, `phone`, `select` | `string`, `long_text` | Unchanged (a dropdown's options are dropped) |
| `select` | `multi_select` | Each value becomes a one-item list |
| `multi_select` | `select` | Each one-item list becomes its option; refused while any record has two or more |
| `number`, `currency`, `percent` | Each other | Unchanged |

Any other change is refused with `400 invalid_type_change` and a sentence saying why; create a new field instead. A change that converts values updates every record holding one, so it sends a `*.updated` webhook for each; records in Recently deleted are converted too where that keeps their value, and otherwise left as they are. If converting takes longer than the request allows, nothing changes and the answer is `503 timeout`.

#### Validation and conditions

Each custom field can carry rules, set in Settings or with `POST /api/v1/crm/fields` and `PATCH /api/v1/crm/fields/:object/:field_name` (each of `validation`, `condition` and `options_by` replaces the stored one; `null` clears it). `GET /api/v1/crm/fields`, `list_crm_fields` and the schema return them on every field, so you can send valid values up front. The database enforces them on writes from the dashboard, the API, MCP, bulk edit, workflows, imports and merges, each as described below. Form submissions, bookings and other system work (restoring a record by a booking, undoing an import) aren't checked, so they never fail over a rule; unique values they write are still tracked.

| Key | Applies to | Meaning |
| - | - | - |
| `validation.pattern`, `validation.preset` | Text, long text, email, link, phone | The whole value must match. `preset`: `email`, `url`, `phone_e164`, `us_zip`, `letters`, `digits`, or `custom` with your own `pattern`: a regular expression of at most 200 characters. `\d`, `\w` and `\s` (and `\D`, `\W`, `\S`) are saved as `[0-9]`, `[A-Za-z0-9_]` and `[ \t\n\r\f\v]`, so they mean ASCII everywhere; `\t \n \r \f \v` and `\` before punctuation are kept. Refused: other letter escapes (`\b`, `\y`, `\x41`, `\p{...}`), back-references, named groups, POSIX classes, unescaped `[` or `]` inside a class, counts above 255, characters beyond U+FFFF, and slow shapes: a repeated group that repeats inside or has alternatives, like `(a+)+` or `([a-z]+-){2,5}`, and a repeated part right after another that can take the same characters, like `\d*\d*` (`[a-z]+[0-9]+` is fine). A refused pattern answers `400` with the reason; the saved form is what `GET` returns |
| `validation.min_length`, `max_length` | Text, long text | Characters |
| `validation.min`, `max`, `integer` | Number, currency, percent | Limits, and whole numbers only |
| `validation.min_date`, `max_date` | Date, date and time | `{ "type": "date", "value": "2026-01-01" }` or `{ "type": "today", "days": 0 }` (`0` with `min_date`: not in the past; negative days count back) |
| `validation.unique` | Text, email, link, phone, number | No two records of the workspace share a value (trimmed, ignoring case; records in Recently deleted and archived contacts don't count). Turning it on is refused with `409 duplicate_values` while records share one, and `details.duplicates` lists them (records you can't open are only counted, in `hidden`). Restoring a record (or making an archived contact active) is refused with `422 not_unique` while another record holds one of its values; restoring several restores the others and lists the refused ones in `refused`. A merge's survivor keeps the values it takes from the record merged into it; a value picked in `values.custom_fields` that a third record holds is refused with `422 not_unique` |
| `validation.message` | With a pattern, length, range or date check | Replaces those checks' sentence |
| `condition` | Any field | `{ "field": "cf:tier", "op": "is", "values": ["Enterprise"], "show": true, "require": true }`. `field` is a dropdown, multi-select or yes/no field (`cf:<name>`) or a built-in: contact `status`, `qualification_status`, `lifecycle_stage`, `lead_status`; company `lifecycle_stage`; deal `status`, `pipeline_id`; task `status`, `priority`, `task_type` (values are keys, as the schema lists them). `op`: `is`, `is_not`, `is_any_of`, `is_empty`, `is_not_empty`. `show`: hidden on record pages unless it holds (the value is kept). `require`: must be filled while it holds |
| `options_by` | Dropdown, multi-select | `{ "field": "cf:region", "options": { "EMEA": ["UK", "DE"], "NA": ["US", "CA"] } }`: the options each value of the other field allows. A value with no entry, or an empty source, allows all of them |

When rules apply:

* A value is checked when it changes (or is set on create). A record holding a value a newer rule refuses can still be edited; that field is checked when someone changes it. Saving a rule answers with `violations: { count, sample }`, how many records don't meet it yet.
* A condition's `require` applies on create and on updates that change the field or the condition's field. A field hidden by its own condition counts as empty in other conditions. Conditions can chain, but not in a loop.
* `options_by` checks the dependent field when it changes. Changing the source keeps a value that no longer fits; record pages mark it.
* Workflows are checked for values (a refused value fails the step with the reason), not for requirements, as with pipeline rules. Imports (contacts, companies, deals, and the tasks of activity imports) leave a refused value out of the row, import the record, and list the value in `warnings` (contact imports also keep them on the import record); a record updated by an import keeps the value it had. If the rows can't be checked, the batch answers `503` and nothing in it is written: send it again. Merges leave a refused value out too, keep the survivor's, and list it in `merge.left_out`. Form submissions and other system work aren't checked.

A refused write answers `422` with every refused field:

```json theme={null}
{
  "error": "validation_failed",
  "error_description": "ZIP must be a US ZIP code, like 94103 or 94103-1234. Region is required when Tier is Enterprise. Fix the listed fields (errors[]) and send the request again; ...",
  "errors": [
    { "field": "zip", "code": "pattern_mismatch", "message": "ZIP must be a US ZIP code, like 94103 or 94103-1234.", "label": "ZIP" },
    { "field": "region", "code": "required_by_condition", "message": "Region is required when Tier is Enterprise.", "label": "Region", "depends_on": "cf:tier" }
  ],
  "details": { "object": "contact", "record_id": "c1d2e3f4-..." }
}
```

| `errors[].code` | Meaning |
| - | - |
| `pattern_mismatch` | The value doesn't match the pattern |
| `too_short` / `too_long` | Fewer characters than `min_length`, or more than `max_length` |
| `out_of_range` | A number or date outside its limits, or a fraction where whole numbers are required |
| `not_unique` | Another record already has the value |
| `required_by_condition` | The condition holds, so the field must be filled (`depends_on` names the condition's field) |
| `option_not_allowed` | The option isn't allowed for the other field's value; `allowed` lists those that are |

A pipeline rule's missing fields (`required_on_create`, `stage_required_fields`, below) are listed in `errors` the same way, with the rule's code.

A key that no field defines is handled differently per endpoint:

| Endpoint | A key with no field definition |
| - | - |
| Companies, deals and tasks (`POST`, `PATCH`) | Rejected with `400 invalid_request` |
| Contacts (`POST /api/v1/contacts`, `PATCH /api/v1/contacts/:id`) | Stored as sent, for keys from forms and integrations. Keys are up to 63 letters, digits, spaces, `_`, `.` or `-`; the value must be text (up to 2,000 characters), a number, `true`/`false`, or `null` to remove the key. A contact holds at most 300 keys and 64 KB of custom fields. Such keys don't appear in the schema until you define them |
| Contact import (`POST /api/v1/contacts/import`) | The request is rejected unless `create_missing_fields` is `true`, which creates each as a text field (that needs `crm:write`) |
| Contact merge (`values.custom_fields`) | Rejected with `400 invalid_request` |

```bash theme={null}
curl -X POST https://app.useorbitcrm.ai/api/v1/crm/fields \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "object": "deal", "label": "Lead source", "field_type": "select", "options": ["Inbound", "Outbound", "Partner"] }'
```

#### Calculated, rollup and synced fields

A custom field can be computed: Orbit works its value out and keeps it current. Send `computed` when you create the field; its `field_type` follows from it. Every field in `GET /api/v1/crm/fields` (and `list_crm_fields`, the schema) carries `computed` (the configuration, or `null`), `computed_summary` (how it's worked out, in words) and `computing` (`true` while a new or changed field fills in every record, usually under a minute).

| Kind | On | `computed` |
| - | - | - |
| Calculated | Contacts, companies, deals, tasks | `{ "kind": "calculated", "formula": "{amount} * {probability} / 100" }` |
| Rollup | Companies (from linked deals or contacts), contacts (from their deals) | `{ "kind": "rollup", "source": "deal", "aggregate": "sum", "field": "amount_home", "filters": [{ "field": "status", "op": "is", "values": ["won"] }] }` |
| Synced | Contacts, deals | `{ "kind": "synced", "source": "industry" }` (a company field: `name`, `domain`, `industry`, `employee_count`, `annual_revenue`, `phone`, `location`, `owner_user_id` or `cf:<name>`) |

* **Formulas** read number, currency, percent, date and date-time fields of the same record in braces (custom fields by name, built-ins such as `{amount}`, `{close_date}`, `{created_at}`), with numbers, `+ - * / ( )` and `ROUND(x, digits)`, `FLOOR`, `CEIL`, `ABS`, `MIN`, `MAX`, `IFEMPTY(x, fallback)`, `DAYS_BETWEEN(from, to)`, `ADD_DAYS(date, n)`, `ADD_MONTHS(date, n)`. Subtracting dates gives days; adding a number to a date gives a date. A number formula can be a `number`, `currency` or `percent` field (send `field_type`); a date formula is a `date` field. An empty input leaves the result empty (except in `MIN`, `MAX` and `IFEMPTY`), dividing by zero is empty, and numbers round to 6 decimal places. Date-times count as their UTC day. A `currency` formula that reads a deal's `{amount}` (and not `{amount_home}`) is in that deal's own currency.
* **Rollups** take `aggregate` `count`, `sum`, `avg`, `min` or `max`, a `field` of the source (not for `count`; `sum` and `avg` need numbers, `min` and `max` also take dates; deal money rolls up as `amount_home`, the amounts converted to the workspace currency, since the raw `amount` is in each deal's own currency and can't be rolled up; `deal_type` and `recurring_interval` can filter `recurring_amount`), and up to 5 `filters` that must all hold (`op`: `is`, `is_not`, `is_any_of`, `is_empty`, `is_not_empty`, `gt`, `gte`, `lt`, `lte`). Records in Recently deleted don't count. A count is `0` with nothing to count; the others are empty.
* **Rollups and synced fields are workspace-wide:** they count every linked record and read the company even when the viewer can't open it, so a member can see totals that include records hidden from them.
* **Synced fields** show the primary company's value (`company_id`) and are empty without one. A company field that synced fields show can't change type or be deleted while they do.

Computed values are read-only everywhere. A write that sends a different value for one answers `400`; sending the stored value back unchanged (a whole record round-tripped) is fine. Imports leave computed values out with a warning, workflows skip them with the reason, and merges work them out again for the merged record. They take no validation or conditions. `PATCH` can change a field's formula, rollup or source (never its kind), which fills every record in again; a field that computed fields read can't be deleted while they do. Filter and sort by them like any other field of their type.

### Record layouts

Admins choose which fields a record page's details cards show, in which card and order, in Settings (each object's fields page, under Record layout). `GET /api/v1/crm/layouts` returns one entry per object (`contact`, `company`, `deal`, `task`; `?object=` for one), with `crm:read`:

```json theme={null}
{
  "data": [
    {
      "object": "company",
      "customized": true,
      "updated_at": "2026-10-31T21:04:00.000Z",
      "config": { "sections": [{ "id": "details", "fields": ["name", "cf:tier", "domain"] }], "hidden": ["industry"] },
      "sections": [
        { "id": "details", "label": "Details", "kind": "fixed", "fields": [{ "key": "name", "label": "Name", "hidden": false }, { "key": "cf:tier", "label": "Tier", "hidden": false }] },
        { "id": "properties", "label": "Properties", "kind": "properties", "fields": [] },
        { "id": "6a1f…", "label": "Billing", "kind": "group", "fields": [{ "key": "cf:po_number", "label": "PO number", "hidden": false }] }
      ],
      "hidden": ["industry"]
    }
  ]
}
```

`sections` is the layout record pages render, complete: every card in order (`fixed` cards such as Details and, on contacts, Lifecycle; `properties` for custom fields in no group; one `group` per [field group](#custom-fields), by its id) and every field in each, built-in fields by column name and custom fields as `cf:<field_name>`. A `hidden` field is still on the page, under All fields. `config` is what an admin saved (`null` and `customized: false` until then, when the default applies: the fixed cards, then Properties, then each group, in the order of `GET /api/v1/crm/fields`). Fields and groups added later join the end of their group's card. Tasks have no built-in fields here: a task's own fields always come first in its dialog.

## Records

| Method | Endpoint | Description |
| - | - | - |
| `GET` | `/api/v1/search` | Search contacts, companies, deals and tasks at once: `q` (2 to 200 characters; every word must match, in any field), `types` (comma-separated, default every type the credential can read), `limit` per type (1 to 25, default 5). Contacts match on name, email (any of their addresses; `matched_email` names another one that matched), company and phone, companies on name, domain and phone, deals on name, tasks on title. Results come grouped by type, strongest first, each with `match` (`exact`, `starts_with`, `word` or `contains`) and the `url` that opens it in Orbit, within the credential owner's record access and visible pipelines. Needs the read scope of each type searched (`403 insufficient_scope` for a type asked for without it) |
| `GET` / `POST` | `/api/v1/contacts` | List (`status`, `search`, `email` (exact, any of the contact's addresses), `tag`, `lifecycle_stage` and `lead_status` (a key, or `none`), `lead_score_min` / `lead_score_max` (whole numbers, [lead score](#lead-score)), `external_id` + `external_source`, `page`, `per_page`, `sort` / `order` ([sorting](#sorting)); `updated_since` / `cursor` for the [changes feed](#changes-feed)) / create. The full contact endpoints are in the [API reference guide](https://docs.useorbitcrm.ai/guides/api-reference.md#contacts) |
| `GET` / `PATCH` / `DELETE` | `/api/v1/contacts/:id` | Read, update, delete a contact. `PATCH` with `external_id` + `external_source` adds the contact's id in another system to `external_ids` (the other systems' ids stay). `DELETE` is restorable: the contact moves to Recently deleted, hidden from every list and lookup but keeping its links and history; `?permanent=true` deletes it for good (both need an admin credential) and, unless `?block=false`, blocks the email from being added again by hand, by import or through the API (`?block_forms=true` also stops forms re-creating it; admins unblock under Settings > Contacts). A blocked email answers `409 blocked` on create. A contact in Recently deleted is a `404` to `GET` and `PATCH`; `POST /api/v1/contacts` with its email revives it (`201`) instead of answering `409` |
| `POST` | `/api/v1/contacts/:id/restore` | Bring a contact back from Recently deleted (admin credential, `contacts:write`) |
| `POST` | `/api/v1/contacts/:id/enrich` | Fill the contact's empty fields (job title, LinkedIn URL) from the workspace's enrichment provider (`contacts:write`). See [Enrichment](#enrichment) |
| `GET` / `POST` | `/api/v1/contacts/:id/emails` | Every email address of the contact, the primary first / add one: `{ "email", "is_primary"? }`. See [More than one email address](#more-than-one-email-address) |
| `PATCH` / `DELETE` | `/api/v1/contacts/:id/emails/:email` | Make the address the primary (`{ "is_primary": true }`) / remove it (not the primary) |
| `GET` / `POST` | `/api/v1/companies` | List (`search`, `domain`, `owner_user_id`, `parent_company_id` for a company's children, `lifecycle_stage` (a key, or `none`), `tag` (slug) or `tag_id`, `external_id` + `external_source`, `sort` / `order` ([sorting](#sorting)); `updated_since` / `cursor` for the [changes feed](#changes-feed)) / create |
| `GET` / `PATCH` / `DELETE` | `/api/v1/companies/:id` | Read (with contact and open-deal counts and `tags`), update, delete. See [Parent and child companies](#parent-and-child-companies). `DELETE` is restorable: the company moves to Recently deleted, hidden from every list, lookup and count, while its contacts, deals, child companies and tasks keep the link for when it comes back (a child company reads as having no parent meanwhile); `?permanent=true` deletes it for good with its notes and files (admin credential). A company in Recently deleted keeps its domain, so creating another company with that domain answers `409` |
| `POST` | `/api/v1/companies/:id/restore` | Bring a company back from Recently deleted, with its links (admin credential, `companies:write`) |
| `POST` | `/api/v1/companies/:id/enrich` | Fill the company's empty fields (industry, employees, location, LinkedIn URL, logo URL) from the workspace's enrichment provider (`companies:write`). See [Enrichment](#enrichment) |
| `POST` | `/api/v1/companies/:id/merge` | Merge `merge_id` into `:id` (`companies:write`): contacts, deals, child companies, notes, tasks, activity, files, tags and followers move to `:id`, and `merge_id` goes to Recently deleted. `values` picks surviving field values. See [Merging companies and deals](#merging-companies-and-deals) |
| `GET` / `POST` / `DELETE` | `/api/v1/companies/:id/tags` | The company's tags; add one (`{ "tag_id" }` or `{ "tag_slug" }`); remove one (`?tag_id=` or `?tag_slug=`). See [Tags](#tags) |
| `POST` | `/api/v1/companies/import` | Import up to 1,000 companies (`external_source`, `duplicate_mode: update \| skip`, `create_missing_fields`); a row matching an existing company by `external_id` or `domain` is updated or skipped |
| `POST` | `/api/v1/companies/import/:import_id/undo` | Undo a companies import: the companies it created move to Recently deleted (restorable), except ones someone edited, linked or added history to since; companies it only updated keep their values (`companies:delete`, admins only). `done: false` means call again |
| `GET` / `POST` | `/api/v1/deals` | List (`pipeline_id`, `stage_id`, `status`, `owner_user_id`, `company_id`, `contact_id`, `search`, `closed_after`, `closed_before`, `tag` (slug) or `tag_id`, `deal_type` (`new_business`, `renewal`, `expansion` or `none`), `renewal_after`, `renewal_before`, `external_id` + `external_source`, `sort` / `order` ([sorting](#sorting)); `updated_since` / `cursor` for the [changes feed](#changes-feed)) / create |
| `GET` / `PATCH` / `DELETE` | `/api/v1/deals/:id` | Read (with `status`, `closed_at`, `lost_reason`, the company, the linked contacts and `tags`), update or move (`stage_id`), delete. `DELETE` is restorable: the deal moves to Recently deleted with its contacts, stage history, tags, notes and files, and leaves the pipeline's counts and totals; its tasks stay, without the link. `?permanent=true` deletes it for good (admin credential) |
| `POST` | `/api/v1/deals/:id/restore` | Bring a deal back from Recently deleted, with its links (admin credential, `deals:write`). If its stage was deleted meanwhile, it comes back in a remaining stage of the same kind (so a won or lost deal stays won or lost), else the first open stage |
| `GET` / `POST` / `DELETE` | `/api/v1/deals/:id/tags` | The deal's tags; add one (`{ "tag_id" }` or `{ "tag_slug" }`); remove one (`?tag_id=` or `?tag_slug=`). See [Tags](#tags) |
| `POST` | `/api/v1/deals/import` | Import up to 1,000 deals, each naming its pipeline and stage by id or name, its company (`company_id` / `company_external_id` / `company_domain`) and contacts (`contact_ids` / `contact_external_ids` / `contact_emails`); a closed deal keeps the `closed_at` it was given. See [Migrating from another CRM](https://docs.useorbitcrm.ai/developers/migrating-from-another-crm.md) |
| `POST` | `/api/v1/deals/import/:import_id/undo` | Undo a deals import, as for companies (`deals:delete`, admins only) |
| `POST` | `/api/v1/deals/:id/merge` | Merge `merge_id` into `:id` (`deals:write`): `:id` keeps its pipeline, stage and status; contacts, companies, stage history, notes, tasks, activity, files, tags and followers move to it, and `merge_id` goes to Recently deleted. See [Merging companies and deals](#merging-companies-and-deals) |
| `GET` | `/api/v1/duplicates?object=contacts\|companies` | Likely duplicate pairs, best first (`page`, `per_page`). See [Finding duplicates](#finding-duplicates) |
| `POST` | `/api/v1/deals/:id/clone` | Copy a deal: `{ "name"?, "stage_id"? }` (default `Copy of <name>`, in the original stage while it is open). See [Cloning deals](#cloning-deals) |
| `GET` / `POST` | `/api/v1/deals/:id/contacts` | The deal's contacts, the primary first, with labels / link a contact: `{ "contact_id", "is_primary"?, "labels"? }` (answers with the deal). See [Associations and labels](#associations-and-labels) |
| `PATCH` / `DELETE` | `/api/v1/deals/:id/contacts/:contactId` | Change a contact's `labels` or make it primary (`is_primary: true`) / unlink it |
| `GET` / `POST` | `/api/v1/deals/:id/companies` | The deal's companies, the primary first, with labels / associate one: `{ "company_id", "labels"?, "is_primary"? }` |
| `PATCH` / `DELETE` | `/api/v1/deals/:id/companies/:companyId` | Change its `labels` or make it the primary company / remove it (the next one becomes primary) |
| `GET` / `POST` | `/api/v1/contacts/:id/companies` | Every company linked to the contact, the primary first, with labels / link one: `{ "company_id", "labels"?, "is_primary"? }` (`contacts:read` / `contacts:write`) |
| `PATCH` / `DELETE` | `/api/v1/contacts/:id/companies/:companyId` | Change a link's `labels` or make it the primary company / unlink it (the next one becomes primary) |
| `GET` | `/api/v1/association-labels` | `{ data: { contact_company, deal_contact, deal_company } }`: the workspace's labels for each kind of link, in order (`?pair=` for one kind; `contacts:read`, `companies:read` or `deals:read`) |
| `GET` / `POST` | `/api/v1/tasks` | List (`status`, `assignee_user_id`, `contact_id`, `company_id`, `deal_id`, `due_before`, `due_after`, `task_type`: one key or several, comma-separated, `sort` / `order` ([sorting](#sorting))) / create. See [Task types and repeating tasks](#task-types-and-repeating-tasks) |
| `GET` / `PATCH` / `DELETE` | `/api/v1/tasks/:id` | Read, update (`"status": "done"` completes; a repeating task then gets its next occurrence), delete. Tasks have `custom_fields` like the other records (defined with `object: "task"`); a `PATCH` merges into them. `DELETE` is restorable (Recently deleted); `?permanent=true` deletes it for good (admin credential) |
| `GET` | `/api/v1/task-types` | `{ data: { task_types } }`: the workspace's task types in order (`tasks:read`). Read-only: the list is edited in Settings → Tasks |
| `GET` | `/api/v1/lifecycle-stages` | `{ data: { lifecycle_stages } }`: the workspace's lifecycle stages in order (`contacts:read` or `companies:read`). See [Lifecycle stage and lead status](#lifecycle-stage-and-lead-status) |
| `GET` | `/api/v1/lead-statuses` | `{ data: { lead_statuses } }`: the workspace's lead statuses in order, each with the `qualification` it sets (`contacts:read`) |
| `GET` | `/api/v1/lead-score-models` | `{ data: { lead_score_models } }`: the workspace's lead scoring models, at most one `active` (`contacts:read`). See [Lead score](#lead-score) |
| `POST` | `/api/v1/tasks/:id/restore` | Bring a task back from Recently deleted (admin credential, `tasks:write`) |
| `GET` / `POST` | `/api/v1/notes` | List notes on one record / add a note |
| `PATCH` / `DELETE` | `/api/v1/notes/:id` | Edit or pin, delete. `DELETE` is restorable (Recently deleted); `?permanent=true` deletes it for good (admin credential). Notes on a company or deal in Recently deleted are hidden with it |
| `POST` | `/api/v1/notes/:id/restore` | Bring a note back from Recently deleted (admin credential, `notes:write`) |
| `GET` / `POST` | `/api/v1/comments` | `GET ?note_id=` or `?activity_id=`: a note's or logged activity's comments, oldest first (`page`, `per_page`). `POST { body, note_id \| activity_id }`: comment as the credential's owner (plain text up to 10,000 characters). A comment is visible exactly when its note or activity is; `notes:read` / `notes:write` |
| `PATCH` / `DELETE` | `/api/v1/comments/:id` | Edit the text (its author only; sets `edited_at`) / delete it for good (its author, or an admin credential) |
| `GET` | `/api/v1/deals/:id/history` | Stage changes, newest first, with who moved it |
| `GET` | `/api/v1/contacts/:id/changes`, `/companies/:id/changes`, `/deals/:id/changes`, `/tasks/:id/changes` | Every field change on the record, newest first, with who made it and from where. See [Field history](#field-history) |
| `GET` | `/api/v1/deals/summary?pipeline_id=` | Per-stage counts, amounts and weighted amounts, status totals, stale count, in the workspace currency (`currency`); `unconverted_count` deals have a currency with no exchange rate and are left out of the amounts. Win rates, conversion and the other sales reports are in the [Reports API](https://docs.useorbitcrm.ai/developers/api/reports.md) |
| `GET` | `/api/v1/currencies` | `{ data: { home_currency, currencies, feed } }`: the workspace currency and the exchange rates of the others (`deals:read`). Read-only. See [Currencies and exchange rates](#currencies-and-exchange-rates) |
| `POST` | `/api/v1/contacts/import` | Import 1–1000 rows (`duplicate_mode`, `tag`, `workflow_id`, `workflow_scope`, `link_companies`, `create_missing_fields`). A workflow runs only when `workflow_id` is given, and that also needs `workflows:write`; `create_missing_fields` needs `crm:write` when it creates fields (`403 insufficient_scope` otherwise). `link_companies` defaults to the team's `company.auto_create_from_contacts` setting. Phones are stored in E.164 using the team's default country (Settings → Dialer) when they parse. A failed call that already saved rows returns `import_id` with the error |
| `GET` | `/api/v1/contact-imports` | Import history with counts and status |
| `GET` | `/api/v1/contact-imports/:id` | One import with its row errors |
| `POST` | `/api/v1/contact-imports/:id/undo` | Delete the contacts an import created and restore the ones it updated (`contacts:delete`, admins only). Large imports are undone in chunks: a `202` with `done: false` and `remaining` means call again |
| `POST` | `/api/v1/contacts/:id/merge` | Merge `secondary_contact_id` into `:id`: tags, notes, tasks, deal links, meetings, bookings, sequence enrollments, sent emails and calls move to `:id`, and the secondary is archived. Where both are enrolled in the same sequence, the secondary's enrollment is stopped. An archived contact can't be merged into (`409`) |

### Sorting

`GET /api/v1/contacts`, `/companies`, `/deals` and `/tasks` take `sort` and `order` (`asc` or `desc`). Each list has its own columns and its own order without them:

| List | `sort` | Without `sort` |
| - | - | - |
| Contacts | `last_seen_at`, `created_at`, `updated_at` | Last form submission, newest first |
| Companies | `name`, `created_at`, `updated_at` | Name, A to Z |
| Deals | `updated_at`, `created_at`, `closed_at` | Last change, newest first |
| Tasks | `due_at`, `created_at`, `updated_at`, `completed_at` | Soonest due first, then newest |

Without `order`, a timestamp sorts newest first, `name` A to Z and `due_at` soonest first. Ties break by id, so pages never repeat or skip a record. Deals with no `closed_at` and tasks with no due date or no completion go last either way, so `sort=closed_at` starts with the deals that closed most recently. `sort` does not combine with the [changes feed](#changes-feed), which lists the oldest change first. A column outside the list, or an `order` other than `asc` / `desc`, is a `400 invalid_request`.

```bash theme={null}
curl "https://app.useorbitcrm.ai/api/v1/contacts?sort=created_at&per_page=3" -H "Authorization: Bearer $ORBIT_API_KEY"   # the three newest contacts
```

### Lifecycle stage and lead status

Contacts and companies have a `lifecycle_stage`, and contacts a `lead_status`: keys from lists each workspace keeps in Settings → Lifecycle (`GET /api/v1/lifecycle-stages`, `GET /api/v1/lead-statuses`, or `lifecycle_stages` / `lead_statuses` in `GET /api/v1/crm/schema`). Every workspace starts with HubSpot's familiar sets:

* **Lifecycle stages**, in order: `subscriber`, `lead`, `marketing_qualified_lead`, `sales_qualified_lead`, `opportunity`, `customer`, `evangelist`, `other` (`subscriber`, `evangelist` and `other` start hidden).
* **Lead statuses**: `new`, `open`, `in_progress`, `attempted_to_contact`, `connected`, `pending`, `qualified`, `open_deal`, `unqualified`, `bad_timing`.

Contacts take both `lifecycle_stage` and `lead_status` on `POST` / `PATCH`; companies take `lifecycle_stage` only. Send a key (or the value's name); `null` clears it. **Keys never change**: a value an admin adds gets a `custom_` key from its first name that stays through every rename. Values are hidden, never deleted: a hidden value can't be given to a record (`400`), but records that have it keep it, and it stays in filters and webhooks.

**Lead status and qualification.** A lead status may carry a qualification. The built-in `qualified`, `unqualified` and `pending` statuses always carry their own, and admins can give one to any other status. A contact whose lead status carries a qualification has that `qualification_status`: setting such a status sets it (sent together with a different `qualification_status`, the lead status wins). Setting `qualification_status` moves a contact with no lead status, or one carrying another qualification, to the matching status (clearing it clears that status); a status that carries none, like `connected`, is left alone. So the AI qualification agent works exactly as before, and its verdict fills an empty lead status.

**Moving forward with deals.** With the deal settings `lifecycle_on_open_deal` and `lifecycle_on_won_deal` (stage keys, off by default; admins; `PATCH /api/v1/crm/settings/deal`), a contact or company linked to an open deal moves to the first stage, and a won deal's contacts and companies to the second. Only forward: a record already at that stage or a later one (by the workspace's order) stays where it is. People and the API can move a stage either way. A company's stage is its own field: it doesn't follow its contacts.

Changes reach `contact.updated` / `company.updated` webhooks (`changes.lifecycle_stage`, `changes.lead_status`), field history and record-change workflows like any other field. The contacts import (`POST /api/v1/contacts/import`, `lifecycle_stage` and `lead_status` per row) and the companies import (`lifecycle_stage`) take a key or a name; a value the workspace doesn't have is left out.

### More than one email address

A contact can have up to 25 email addresses. `email` is the primary: the one Orbit sends to, and the one every filter, export and merge field reads. `emails` (on `GET /api/v1/contacts`, `GET /api/v1/contacts/:id` and the answers to creates and updates) lists every address, the primary first.

* An address belongs to one contact in a workspace, primary or not (ignoring case). Adding one another contact has, or creating a contact with it, answers `409 conflict`.
* Forms, bookings, inbound email and replies, imports, the `email` filter and `search` find a contact by any of its addresses. An unsubscribe from any address stops sequences for the contact.
* `POST /api/v1/contacts` takes `emails` (more addresses for the new contact). `PATCH /api/v1/contacts/:id` takes `add_emails` and `remove_emails` (lists of addresses); setting `email` to one of the contact's other addresses makes it the primary, and the old primary stays as another address.
* `POST /api/v1/contacts/import` rows take `emails` too: each address no contact has is added (one another contact has is left where it is), and undoing the import removes them.
* Merging two contacts keeps every address: the kept contact's primary stays, and the merged contact's addresses become its other addresses.
* Adding or removing an address sends `contact.updated` with `emails`, and shows in the contact's field history.

### Lead score

Every contact has a `lead_score`: a whole number Orbit keeps up to date from the workspace's active scoring model (Settings → Lead scoring), or `null` when the contact was never scored (or no model is active). It's **read-only**: a `POST` or `PATCH` that names `lead_score` is refused with `422` and `errors: [{ "field": "lead_score", "code": "read_only" }]`, and imports, merges and the MCP tools can't set it either.

A model (`GET /api/v1/lead-score-models`) adds up:

* **Fit rules**, `{ id, label, field, operator, value, points }`: a condition in the contacts filter vocabulary (`GET /api/v1/views?object=contacts` lists the fields and operators), on a contact field, a custom field (`cf:<field_name>`) or a field of the contact's companies (`company.<field>`). A matching rule adds its points (which can be negative).
* **Engagement rules**, `{ id, label, event, points, max_points, form_id }`: points per event, where `event` is `form_submitted` (optionally one `form_id`), `email_replied` (an email from the contact), `meeting_booked`, `meeting_held` or `call_connected`. `max_points` caps one rule's total.
* **Decay**, `decay_half_life_days`: when set, an event's points halve every that many days.

The score is the rounded sum. It's recalculated as the contact, their companies, tags and activity change, when the model changes, and nightly for decay. A change reaches `contact.updated` webhooks (`changes.lead_score`) and "Field changed" workflow triggers, but doesn't move the contact's `updated_at`. Filter lists with `lead_score_min` / `lead_score_max`, or in saved views with the `leadScore` filter field (`at_least`, `at_most`, `between` with `"low,high"`, `is_empty` for never scored).

### Associations and labels

A contact can be linked to several companies, and a deal to several contacts and companies. Each record has one **primary**: `company_id` on a contact or deal is its primary company, exactly as before, and a deal's primary contact is the one with `is_primary`. Setting `company_id` on `POST` / `PATCH` makes that company the primary and keeps the previous one linked; `company_id: null` removes the primary company's link (the others stay, none primary). Removing a link that is the primary makes the earliest remaining one primary. The first company linked to a record without one becomes its primary.

Links carry **labels**: how a person relates to a company ("Billing contact"), the role a contact plays on a deal ("Decision maker", "Champion"), or a company's part in a deal ("Reseller"). Each workspace keeps its own labels per kind of link in Settings → Associations (at most 50 per kind; `GET /api/v1/association-labels` lists them). Send `labels` as keys or names; they replace the link's labels, and `[]` clears them. **Keys never change**; a hidden label can't be given to a link (`400`), but links that have it keep it.

`GET /api/v1/contacts/:id?include=associations` adds `companies`, and `GET /api/v1/deals/:id?include=associations` adds `companies` and each contact's `labels`, so ordinary reads stay small. Each association reads `{ company_id | contact_id, company | contact, is_primary, labels: [{ key, label }], created_at }`.

Associations reach webhooks as `contact.association_changed` and `deal.association_changed`, and filters can use them: on Contacts, `company.*` matches the primary company or any linked one and `companyLabel` a label on any of the contact's company links; on Companies, `contactCount` counts every linked contact and `contactLabel` matches a label on any of its contact links.

### Changes feed

`GET /api/v1/contacts`, `/companies` and `/deals` list what changed since a moment when given `updated_since` (an ISO 8601 date or date-time; a bare date is the start of that day, UTC): records whose `updated_at` is at or after it, oldest change first, `per_page` at a time (100 at most). The other filters still apply. A page carries no total; `meta` says whether there is more and where to continue:

```json theme={null}
{
  "data": [ { "id": "7c1d2e3f-...", "updated_at": "2026-10-30T12:00:00.123456+00:00", "...": "..." } ],
  "meta": { "per_page": 100, "has_more": true, "next_cursor": "eyJ1IjoiMjAyNi0x..." }
}
```

Pass `next_cursor` back as `cursor`, with the same filters, until `has_more` is `false`. A cursor holds the position exactly (records changed in the same instant are neither repeated nor skipped); one this API did not hand out is a `400`. A record changed while you page moves to the end of the feed, so you see its latest version.

An integration that keeps another system in sync reads the feed now and then to catch what its webhooks missed: start each run from the time the previous one started, minus a few minutes (a change is stamped when its transaction began, so one that committed late can carry an earlier time), and treat records you have already applied as no-ops. Records in Recently deleted are not listed; deletions arrive as `*.deleted` webhooks, and a `PATCH` to a deleted record answers `404`. See [Build a two-way sync](https://docs.useorbitcrm.ai/apps/two-way-sync.md).

### Field history

Every change to a contact's, company's, deal's or task's fields is recorded: the field, the value before and after, who made it, where it came from and when. `GET /api/v1/{contacts,companies,deals,tasks}/:id/changes` lists a record's changes, newest first. It needs the record's read scope (`contacts:read`, `companies:read`, `deals:read`, `tasks:read`); a credential with another of the four gets `403 insufficient_scope` naming the one it needs:

```json theme={null}
{
  "data": [
    {
      "id": "48211",
      "object": "deal",
      "record_id": "7c1d2e3f-...",
      "kind": "update",
      "field": "stage_id",
      "old_value": "2f6a...",
      "new_value": "91bc...",
      "source": "api",
      "actor": { "user_id": "a1b2...", "app_id": null },
      "changed_at": "2026-10-30T12:00:00.123456+00:00"
    }
  ],
  "meta": { "per_page": 25, "has_more": true, "next_cursor": "48211" }
}
```

* `field` is a column (`amount`, `owner_user_id`, ...) or `custom_fields.<key>` for a custom field: each key is its own change. `old_value` and `new_value` are the stored values (ids for owners, stages and linked records; `null` is empty).
* `kind` is `update` for a field change, `deleted` / `restored` when the record moved into or out of Recently deleted (no field or values), and `merged` when another company or deal was merged into this one (`new_value` is `{ "merged_from": { "id", "name" } }`) or this one into another (`{ "merged_into": { "id", "name" } }`). A merge's field changes on the kept record are ordinary `update` entries; the merged record keeps its own history.
* `source` is where the change came from: `dashboard`, `api`, `mcp`, `import`, `workflow`, or `system` (Orbit itself: public bookings and form submissions, unsubscribe links, background jobs). `actor.user_id` is the member who made it (for the API and MCP, the credential's owner) and `actor.app_id` the connected app, as in webhook deliveries.
* Filter with `field`, `source` and `actor` (a user ID, or `none` for changes no member made). `per_page` is 1 to 100 (default 25); pass `meta.next_cursor` back as `cursor`, with the same filters, until `has_more` is `false`.
* Recorded fields: the ones the record pages edit (contacts: email, names, phone, company, job title, status, qualification, owner, do-not-call; companies: every built-in field; deals: name, pipeline, stage, amount, currency, close date, owner, primary company, lost reason, description; tasks: title, description, due date, priority, status, type, repeat, reminder, assignee and links) and every custom field. Counters and dates Orbit keeps itself are not.
* History is kept for as long as the record exists. A record the credential can't see, or one in Recently deleted, answers `404`; deleting a record permanently deletes its history. Deals keep `/deals/:id/history` for their stage moves.
* A value [enrichment](#enrichment) wrote is a `system` change with `enriched_by` naming the provider (`"moltsets"`); other changes have no `enriched_by`.

### Enrichment

When a workspace connects an enrichment provider (Settings > Integrations > Moltsets, with the workspace's own Moltsets API key), Orbit fills empty fields on contacts and companies: new records shortly after they're created (not records imports and CRM migrations bring in: an admin enriches those with **Enrich the N imported records**), existing ones when an admin runs **Enrich my entire CRM**, and any record on request with `POST /api/v1/contacts/:id/enrich` or `POST /api/v1/companies/:id/enrich` (`contacts:write` / `companies:write`; also the MCP tools `update_contact` and `update_company` with `"enrich": true`, and the **Enrich Record** workflow step).

* Contacts are looked up by their primary email and get `job_title` and the custom field `linkedin_url`. Companies are looked up by `domain` and get `industry`, `employee_count`, `location` and the custom fields `linkedin_url` and `logo_url`. Connecting adds those custom fields to the workspace unless fields with those names exist already.
* A field is written only when it's empty, or still holds the value enrichment wrote last time. A value a person, import, workflow or API client entered is never replaced. Calculated, rollup and synced fields are never written, and a value a field's validation rules refuse is left out.
* The response is `{ "data": { "outcome", "applied", "skipped", "errors", "message", "provider", "object", "record_id" } }`. `outcome` is `enriched` (fields written; `applied` lists them as columns or `custom_fields.<key>`), `unchanged` (found, nothing to fill; `skipped` says why per field: `filled`, `unchanged`, `computed`, `no_field`, `wrong_type`, `invalid`), `not_found`, or `skipped` (no email or domain to look up by).
* `409 enrichment_not_connected` / `enrichment_disabled` / `enrichment_blocked` when the workspace has no provider, has enrichment switched off, or the provider refused its key or account; `429 enrichment_paused` / `enrichment_rate_limited` while the provider's usage limit is reached; `502 enrichment_unavailable` when the provider can't be reached. A record the credential can't see is a `404`; a viewer's credential gets `403`.

## Activity, timeline and files

What Orbit did itself (composer emails, dialer calls, bookings, sequence sends, form submissions) is already on a record. These endpoints add what happened elsewhere, read it all back in one feed, and attach files.

| Method | Endpoint | Description |
| - | - | - |
| `GET` | `/api/v1/timeline?contact_id=\|company_id=\|deal_id=` | Everything on one record, newest first: submissions, meetings, sequence sends, emails, calls, logged activities, notes, tasks, (deals) stage changes, website visits (`web_visit`: one item per visit, its pages in `data.pages`, when the workspace tracks them), forms the Orbit Pixel captured on the workspace's own site (`web_form`: `data` has `form_name`, `page_url` and `fields`, each `{n, l, v}`) and job changes enrichment found on a re-check (`job_change`: `data` has `change`, `previous_company`, `company`, `previous_title`, `title` and `provider`). `types` filters (comma-separated), `before` pages (pass back `next_cursor`), `limit` up to 100. A company's timeline includes its contacts' activity; every item carries `contact_id` plus `contact_name` and `contact_email` when the item is about a contact |
| `POST` | `/api/v1/summaries` | AI summary of one record: `{ object: contact \| company \| deal, id, refresh? }`. Needs `activities:read` and the object's read scope, and the summary reads only what the credential could read itself: the timeline as `GET /api/v1/timeline` returns it (logged, forwarded and synced email bodies included), plus bodies of emails sent from Orbit with `emails:read`, call transcripts and AI call reviews with `dialer:read`, full notes with `notes:read`, meeting notes with `meetings:read`, deals and deal totals with `deals:read`, people and people counts with `contacts:read`, the company with `companies:read` and teammates' names with `crm:read` or `team:read`. Returns `{ status: "ready", summary: { summary, next_steps, facts, sources }, generated_at, cached, credits_used }`: 3-6 sentences citing timeline items as `[n]`, 2-4 next steps, key facts (last touch, next activity, open deals, recent calls and meetings) and the cited sources. An unchanged record returns its saved summary (`cached: true`, no credit); a new one, or `refresh: true`, uses 1 AI credit (`402` without credits), except that refreshing an unchanged record within 10 minutes of its summary returns that summary again (`up_to_date: true`, no credit). A workspace runs at most 200 paid AI actions a day (`429`). A record with almost nothing on it answers `status: "empty"` |
| `GET` / `POST` | `/api/v1/activities` | List logged activities on one record (`type`, `direction`, `since`, `until`) / log one: `{ type: email \| call \| meeting \| sms \| message, occurred_at, direction?, title?, body?, body_html?, ended_at?, duration_seconds?, outcome?, status?, participants?, location?, external_url?, external_source?, external_id?, actor_user_id? \| actor_email? \| actor_name?, ...links, metadata? }`. Nothing is sent |
| `GET` / `PATCH` / `DELETE` | `/api/v1/activities/:id` | Read, update (any field but `type`; `null` unlinks a record), delete. Emails synced from a member's Gmail or Outlook mailbox (`metadata.transport` `gmail` or `outlook`) are listed and read like other activities but are read-only: `PATCH` and `DELETE` answer `403 synced_mail_read_only` |
| `POST` | `/api/v1/activities/import` | Import up to 500 rows of history in one call: notes, tasks and logged activities with their original timestamps, authors and ids (`external_source` + `external_id` per row make re-runs update instead of duplicate; `duplicate_mode: update \| skip`). Rows of type `note` / `task` also need `notes:write` / `tasks:write`. Returns `import_id` with created, updated, skipped and error counts |
| `GET` | `/api/v1/activity-imports`, `/api/v1/activity-imports/:id` | Import history, and one import with its row errors |
| `POST` | `/api/v1/activity-imports/:id/undo` | Delete everything an import created (admins only); `done: false` means call again |
| `GET` / `POST` | `/api/v1/files` | List files on one record (`contact_id`, `company_id`, `deal_id`, `activity_id`, `note_id`) / attach one: `multipart/form-data` with a `file` part and the link fields (small files; request bodies are capped at 4.5 MB), or JSON `{ storage_path, ...links }` to complete an upload started below |
| `POST` | `/api/v1/files/uploads` | Start an upload of up to 25 MB: `{ file_name, size_bytes, mime_type?, ...links }` returns a `signed_url` to `PUT` the bytes to, then `POST /api/v1/files` with its `storage_path` |
| `POST` | `/api/v1/files/import` | Fetch a file from an HTTPS URL you can reach (a signed download link from another CRM) and attach it: `{ url, file_name?, ...links, external_id? }`. Public hosts only, 25 MB at most; documents, images, archives, audio and video |
| `GET` / `DELETE` | `/api/v1/files/:id` | The record plus a `download_url` that works for five minutes / delete the record and the bytes |

**Mentions.** A note or comment mentions a member with a token in its text, `@[Sam Lee](user:<user id>)` (member ids come from `GET /api/v1/crm/schema` or `/api/v1/me`). Responses list the members it mentions in `mentioned_user_ids`, and each one is notified (in Orbit and, if their settings say so, by email), except people who can't see the record, who are never told about it. `POST` / `PATCH /api/v1/comments` refuse a token naming someone outside the workspace (`400`); in notes such a token stays text.

**Naming records.** Every link accepts the Orbit id (`contact_id`, `company_id`, `deal_id`), a natural key (`contact_email`, `company_domain`) or the record's id in another system (`contact_external_id`, `company_external_id`, `deal_external_id`, resolved through the `external_ids` that `POST /api/v1/contacts`, `/contacts/import`, `/companies` and `/deals` store when given `external_id` + `external_source`). A migration therefore passes the old system's ids straight through.

**Backdating.** `created_at` may be set when creating a contact, company, deal, note or task (and `completed_at` on a task), and every logged activity carries its own `occurred_at`, so migrated history lands at the right place on the timeline. Notes and tasks also take `author_name` for an author who is not a workspace member; activities take `actor_email` / `actor_name`.

```bash theme={null}
curl -X POST https://app.useorbitcrm.ai/api/v1/activities/import \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "external_source": "hubspot",
    "rows": [
      { "type": "note", "external_id": "note-8812", "contact_external_id": "301", "created_at": "2024-03-05T10:12:00Z", "author_name": "Grace Hopper", "body": "<p>Met at SaaStr, wants a demo</p>" },
      { "type": "email", "external_id": "email-4471", "contact_external_id": "301", "occurred_at": "2024-03-06T15:02:00Z", "direction": "outbound", "title": "Demo on Thursday?", "status": "replied", "body_html": "<p>Hi Ada ...</p>", "participants": { "from": "grace@acme.com", "to": ["ada@example.com"] } },
      { "type": "call", "external_id": "call-9920", "contact_external_id": "301", "deal_external_id": "77", "occurred_at": "2024-03-07T09:30:00Z", "direction": "outbound", "duration_seconds": 840, "outcome": "connected", "body": "Walked through pricing" }
    ]
  }'
```

## Teams and pipeline access

Teams (member groups) and pipeline visibility use the `crm:read` / `crm:write` scopes; changes need a credential whose owner is a workspace admin.

| Method | Endpoint | Description |
| - | - | - |
| `GET` / `POST` | `/api/v1/member-groups` | List teams with their members / create `{ name, user_ids? }` |
| `GET` / `PATCH` / `DELETE` | `/api/v1/member-groups/:id` | Read, rename, delete (members stay in the workspace) |
| `PUT` / `POST` | `/api/v1/member-groups/:id/members` | Replace the member list `{ user_ids }` / add one `{ user_id }` |
| `GET` / `PUT` | `/api/v1/pipelines/:id/access` | `{ visibility: "everyone" \| "restricted", user_ids, group_ids }`; a restricted pipeline, its deals, notes and tasks are visible only to the people and teams listed (and admins) |

### Bulk imports

`POST /api/v1/companies/import` and `POST /api/v1/deals/import` take `{ rows, external_source?, duplicate_mode?, create_missing_fields? }` with up to 1,000 rows, like the contacts import. Every row may carry `external_id` (its id in the source system, stored under `external_source`), `created_at` (on a row that matches an existing record, it applies when it is earlier than the record's own) and `tags` (a list, or text separated by commas or semicolons: existing tags are matched by name ignoring case, missing ones are created, and they are added to the record; a skipped row's record keeps its tags); a deal row may name its pipeline and stage by `pipeline_name` / `stage_name`, its company by `company_external_id` or `company_domain`, and its contacts by `contact_external_ids` or `contact_emails`. A deal row without a pipeline or stage creates the deal in the default pipeline's first stage and leaves an existing deal where it is; its `closed_at` applies when that stage is closed. A new deal's `stage_entered_at` (when it entered its stage in the source system) starts its time in stage and stage history; without it they start at `closed_at` for a closed deal, else `created_at`. A row that matches a record outside your record access is reported as an error, as `PATCH` on it would be. A `custom_fields` key that names a built-in field is not a custom field: `tags` is read as the row's tags, and any other (`parent_company_id`, `status`, ...) is left out with a warning rather than failing the batch. The response lists created, updated and skipped counts (`merged`, part of `skipped`, counts rows whose `external_id` belongs to a record merged into another one in Orbit: they are not created again, and their id in `records` is the record they went into), row `errors`, `warnings` (a contact that could not be found, a value left out, a tag that could not be added) and the Orbit id of every row. Rows are written in chunks; if a write fails part-way the error response carries `partial` with the counts, the ids of the rows already written and the rows rejected, so a retry can resend the rest (rows with an `external_id` are safe to resend either way). Every response (a failed one that already wrote records too) carries `import_id`: pass it as `import_id` on the next call to add that call's records to the same import, give the first call a `label` to name it, and `POST /api/v1/companies/import/:import_id/undo` (or `/deals/import/...`) undoes the whole import. A new deal's stage history from `stage_entered_at` is marked as imported from `external_source` (`imported_from` on `GET /api/v1/deals/:id/history`); it counts toward time in stage and the stage reports like any other change. Imports do not start "Deal Stage Changed" workflows, and the records they write are not delivered to the workspace's webhooks or connected apps unless an owner turned on **Send imported records to integrations** (Settings → Integrations → Connected apps); see [Imports](https://docs.useorbitcrm.ai/developers/webhooks/overview.md#imports). The limits and the recommended order are on [Migrating from another CRM](https://docs.useorbitcrm.ai/developers/migrating-from-another-crm.md).

### Parent and child companies

A company can have one parent company in the same workspace. Set it with `parent_company_id` on `POST /api/v1/companies` or `PATCH /api/v1/companies/:id` (`null` makes the company top level), and list a company's children with `GET /api/v1/companies?parent_company_id=`. A parent that is the company itself or one of its own descendants is refused with `400 invalid_request` (`"That company is already below this one in the hierarchy, so it can't be its parent"`). A parent outside your workspace or outside your record access answers `400` with `parent_company_id is not a company in this team`, the same as an id that doesn't exist, in the API and in the dashboard alike. The new parent's chain (the parent and the companies above it) can be at most 100 companies long; this is checked when a parent is set, so moving a company that has children of its own can leave a deeper branch below it. If two changes to the same workspace's hierarchy collide, one may answer `409 conflict` (`Someone else changed this hierarchy at the same moment. Try again.`); retrying it is safe. Deleting a parent leaves its children in place, without a parent. A change of parent is a `company.updated` webhook with `parent_company_id` in `changes`.

### Tags

Contacts, companies and deals share one tag library: create, rename and delete tags with `/api/v1/contact-tags` (`contacts:write`). `POST /api/v1/companies/:id/tags` and `POST /api/v1/deals/:id/tags` add a library tag by `tag_id` or `tag_slug`: `201` with `{ "tag", "added": true }`, or `200` with `"added": false` when the record already had it. `DELETE` with `?tag_id=` or `?tag_slug=` answers `{ "tag", "removed" }`, `removed: false` when the record didn't have it, so both calls are safe to retry. An unknown tag is a `404`; so is a company or deal outside your record access. `GET /api/v1/companies/:id` and `GET /api/v1/deals/:id` include `tags` as `[{ "id", "name", "slug", "color" }]`, and the `tag` filter on the lists takes a slug (an unknown slug matches nothing). Adding and removing tags sends `company.tag_added` / `company.tag_removed` and `deal.tag_added` / `deal.tag_removed` webhooks.

```bash theme={null}
curl -X POST https://app.useorbitcrm.ai/api/v1/companies/COMPANY_ID/tags \
  -H "Authorization: Bearer $ORBIT_API_KEY" -H "Content-Type: application/json" \
  -d '{ "tag_slug": "enterprise" }'
```

### Task types and repeating tasks

Every task has a `task_type`. Each workspace starts with three built-in types, `call`, `email` and `todo`; in Settings → Tasks an admin can rename and reorder them, hide `call` or `email`, and add the workspace's own. **Keys never change**: a custom type gets a `custom_` key from its first name (`LinkedIn message` → `custom_linkedin_message`) that stays the same through every rename. A task created without `task_type` is a `todo`, so existing integrations keep working. A hidden type can't be given to a task (`400 task_type_hidden`), but tasks that already have it keep it; a key the workspace doesn't have is `400 unknown_task_type`. `GET /api/v1/task-types` (MCP: `list_task_types`) lists them, each with `key`, `label`, `icon`, `hidden`, `built_in` and `position`.

A task repeats when it has `repeat_unit` (`day`, `week` or `month`) and `repeat_interval` (1–365, default 1): daily, weekly and monthly are an interval of 1, and anything else is a custom interval such as every 2 weeks. A repeating task needs a `due_at`. When it is completed, by anyone and from anywhere (the app, the API, MCP, bulk actions), Orbit creates the next task: the same title, details, type, priority, assignee, links, custom fields, reminder and repeat, due on the next date of the series that is later than both the completed task's due date and now, so a task completed late skips the dates it missed. The series is counted in `repeat_time_zone` (an IANA name, default `UTC`), so a task due at 17:00 stays at 17:00 across daylight saving, and a monthly task due on the 31st comes back on the last day of shorter months. Moving a repeating task's `due_at` restarts the series from the new date. Each task creates at most one next task: completing, reopening and completing again doesn't make a second one, and reopening a task removes the next task it created while nobody has changed it. The next task carries `recurrence_parent_id`, the task it came from. `"repeat_unit": null` stops a task repeating.

**Reminders.** `reminder_minutes` reminds the task's assignee that many minutes before `due_at` (`0` at the due time, up to `40320`, four weeks; `null` removes it). The task returns `remind_at`, when the reminder goes out. It reaches the assignee as a notification in Orbit and by email, per their notification settings (Task reminders), and replaces the "Task due" notification for that task. Changing `due_at` or `reminder_minutes` reminds again at the new time; a completed, deleted or unassigned task isn't reminded. A repeating task's next occurrence keeps the same reminder.

```bash theme={null}
curl -X POST https://app.useorbitcrm.ai/api/v1/tasks \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "title": "Send the weekly pipeline report", "task_type": "email", "due_at": "2026-11-06T17:00:00-05:00", "repeat_unit": "week", "repeat_interval": 1, "repeat_time_zone": "America/New_York", "reminder_minutes": 60 }'
```

### Prospecting queue

`GET /api/v1/prospecting/queue` (MCP: `get_prospecting_queue`, scope `contacts:read`) returns the credential owner's Prospecting queue, the same list the **Prospecting** page shows them: one row per contact (a task without a contact is a row of its own), most urgent first, each with its `reasons`:

| Reason | What it means |
| - | - |
| `reply` | The contact wrote back in the last 7 days to an email they were sent in the 60 days before (from Orbit, by a sequence, or a logged outbound email), the message is not auto-generated, and nobody has contacted them since. The reason carries a `snippet` of the message, the thread ids a reply needs (`thread_key`, `message_id`, `references`), the Inbox `conversation_id` and `from_sequence` |
| `task_overdue` / `task_due` | The owner's open tasks due before today, or later today (in `time_zone`, default `UTC`). Included when the credential also has `tasks:read` |
| `new_lead` | A contact they own, created in the last 14 days and never contacted |
| `open` | An email they sent in the last 7 days was opened, with no contact since |
| `stale` | A contact they own not contacted for the workspace's `prospecting_stale_days` (contact settings, default 30; `null` turns it off) |

New leads and stale contacts come only from the lead statuses reps are still working: the contact setting `prospecting_lead_statuses` (lead status keys, plus `"none"` for contacts without one). Left `null`, that is every status except those that carry a qualified or unqualified qualification, `open_deal` and `bad_timing`. There is no separate leads object: the lead status is the stage. Each row also carries the contact's open `deal` (`id`, `name`, `amount`, `currency`, `stage`; the latest one the owner can see; needs `deals:read`) and the `sequence` they are enrolled in (`sequence_id`, `name`, `step`, `steps`; needs `sequences:read`), or `null`. The response also lists `meetings` (`today`: the owner's meetings today as host, in order; `needs_outcome`: their meetings of the last 7 days without an outcome; needs `meetings:read`), `today` (what the owner has done since their day began in `time_zone`: `tasks_completed`, `emails_sent`, `calls`, `meetings_booked`; the emails and calls need `activities:read`, the meetings `meetings:read`, and the rest reads `0`), `snoozed` contacts (hidden from the queue until a time the owner chose in the app), `status_counts` (the contacts they own per lead status) and the `settings` it was built from. A reply's `sender_email` is the address the contact wrote from, which may differ from their record's email. Query: `filter` (`all`, `replies`, `tasks`, `new_leads`, `opens` or `stale`), `limit` (1–250, default 50; `has_more` says when rows were left out) and `time_zone`. Record access applies: rows only name contacts, companies and deals the owner can see.

### Cloning deals

`POST /api/v1/deals/:id/clone` (`deals:write`) creates a new deal from an existing one and answers `201` with it. The copy is in the same pipeline, with the same amount, currency, close date, owner, company, contacts (the primary contact stays primary), description and custom fields, and is named `Copy of <name>` unless the body gives `{ "name": "..." }`. Notes, activity, tasks, files, stage history, `lost_reason`, `closed_at` and `external_ids` are not copied, and the original is not changed. The copy starts its own stage history and emits [`deal.created`](https://docs.useorbitcrm.ai/developers/webhooks/events#deals) like any new deal.

**Stage.** The copy starts in the original's stage while that stage is open. A won or lost deal's copy starts in the pipeline's first open stage instead, so it isn't counted in won totals again and sends no second `deal.won` / `deal.lost`. Pass `"stage_id"` to choose any stage of the same pipeline (another pipeline's stage is a `400`). A closed stage chosen this way follows the usual rules for new deals: a copy created in a won or lost stage is closed now and sends `deal.won` / `deal.lost`, and it can't start in a lost stage when the workspace requires a lost reason (`400`). The body is optional and takes only `name` and `stage_id`.

The same access rules as `GET /api/v1/deals/:id` apply: a deal in a pipeline you can't see, or outside your record access, is a `404`. Contacts or a company you can't see, and contacts in Recently deleted, are left off the copy; if the original's owner has left the workspace, the copy gets an owner from the `default_owner` setting. Custom field values that no longer fit their field (a removed select option, a deleted field) are dropped. A deal with more than 200 contacts can't be cloned (`400`).

```bash theme={null}
curl -X POST https://app.useorbitcrm.ai/api/v1/deals/DEAL_ID/clone \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Acme renewal 2027", "stage_id": "STAGE_ID" }'
```

### Merging companies and deals

`POST /api/v1/companies/:id/merge` and `POST /api/v1/deals/:id/merge` fold one record into another in a single transaction. `:id` survives; the body names the other record:

```json theme={null}
{ "merge_id": "7e8f9a0b-...", "values": { "name": "Acme", "domain": "acme.io", "custom_fields": { "tier": "gold", "region": null } } }
```

* **Values.** For each field the surviving record keeps its own value, and takes the merged record's where it has none. `values` overrides that per field, with the same validation as `PATCH`: companies take `name`, `domain`, `industry`, `employee_count`, `annual_revenue`, `phone`, `location`, `description`, `owner_user_id`, `parent_company_id`; deals take `name`, `amount`, `currency`, `close_date`, `owner_user_id`, `company_id`, `description`. `null` clears a field (not `name`). `custom_fields` works per key the same way (`null` removes one). `external_ids` are combined, the survivor's winning on a clash. A company's `lifecycle_stage` is the further of the two by the workspace's order.
* **What moves.** Everything linked to the merged record moves to the survivor, including records in Recently deleted: a company's contacts, deals (as primary or associated company), child companies, notes and their comments, tasks, logged activity, files, tags and followers; a deal's contacts (the survivor keeps its primary contact, else takes the merged deal's), companies, stage history, notes, tasks, activity, files, tags and followers. A deal's moved stage history is kept for the record (marked `merged_from_deal_id` in `GET /deals/:id/history`) but left out of its stage times and of the sales reports' conversion and time-in-stage numbers.
* **Deals keep their place.** The surviving deal keeps its pipeline, stage, status and lost reason, whatever the merged deal's were; no stage is entered, so pipeline rules are not checked and `deal.stage_changed` is not sent. The merged deal's amount comes with its own currency. `company_id: null` leaves the survivor without a primary company, as clearing it on a deal does (that company's link goes; the others stay). A won or lost deal in a pipeline that locks closed deals can only be kept by an admin's merge (`409` otherwise); the deal merged away may be locked, as a locked deal may be deleted.
* **Quotes move, line items stay.** The merged deal's quotes move to the survivor (`quote.updated` with `changes.deal_id`), so a buyer's quote link and its payment link keep working. Its line items stay on the merged deal: the survivor keeps its own products and amount.
* **The merged record** goes to Recently deleted (an admin can still see its values there; restoring it brings back an empty record). A company's domain moves with it when the survivor takes it; otherwise the merged company keeps holding it, so new contacts at that domain are not auto-linked to a fresh duplicate. Its external ids keep pointing at the survivor: an import (or a HubSpot "Import again") skips a row with such an id, counted in `merged`, instead of creating the record again, and a deal or activity that names it by external id is linked to the survivor.
* **History.** Each record keeps its own field history. Both get a `merged` entry, and the survivor's changed fields are recorded as ordinary changes (each can be undone). See [Field history](#field-history).
* **Webhooks.** [`company.merged` / `deal.merged`](https://docs.useorbitcrm.ai/developers/webhooks/events) carry the survivor, the merged record as it was and what moved. The merged record's move to Recently deleted sends `company.deleted` / `deal.deleted` first, and moved contacts, deals and tasks send their own `*.updated`.
* **Access.** Both records must be visible to the credential's owner (record access, and pipelines for deals), and the owner must be an admin or editor. A record in Recently deleted can't be merged (`409`); a survivor `domain` another company holds is a `409`. `merge.moved` counts every record that moved, including ones the credential's owner can't see. Another write to the same records at the same moment answers `409 busy`: try again.

The answer is the surviving record as `GET` returns it, plus `merge`:

```json theme={null}
{
  "data": {
    "id": "3a4b5c6d-...",
    "name": "Acme",
    "...": "...",
    "merge": { "merged_id": "7e8f9a0b-...", "moved": { "contacts": 3, "deals": 2, "child_companies": 0, "notes": 1, "tasks": 0, "activities": 14, "files": 0, "tags": 1, "followers": 1 } }
  }
}
```

Contacts merge with `POST /api/v1/contacts/:id/merge` (`secondary_contact_id`), which archives the merged contact instead.

### Finding duplicates

`GET /api/v1/duplicates?object=companies` (`companies:read`) or `?object=contacts` (`contacts:read`) lists pairs of records that are probably the same, best first, as the Duplicates pages in the app show them:

```json theme={null}
{
  "data": [
    { "score": 0.99, "reasons": ["same_domain", "same_name"], "records": [{ "id": "...", "name": "Globex Corporation", "domain": "globex.com", "...": "..." }, { "id": "...", "name": "Globex", "domain": "eu.globex.com", "...": "..." }] }
  ],
  "meta": { "page": 1, "per_page": 20, "total": 37, "truncated": false }
}
```

* Contacts match on `same_email` (the same address apart from case), `same_phone` (the same number, unless the two names differ: a shared office line), `same_name_company` (the same full name at the same company) and `similar_email` (the same full name, and the same mailbox on another domain or a similar one on the same domain).
* Companies match on `same_domain` (the same site: the same host apart from `www`, or one of them the bare domain of the other, so `acme.com` and `eu.acme.com` match but two different subdomains such as `shopify.zendesk.com` and `cloudflare.zendesk.com` don't; each `*.myshopify.com` shop and other hosted or shared-mail subdomain is its own, and a free-mail domain on a company never counts), `same_name` (the same name without case, punctuation and legal suffixes: "Acme, Inc." and "ACME"; a one-word name on two companies without domains scores low) and `similar_name` (close spellings, never two companies with different domains or numbers in their names).
* `score` is 0 to 0.99; several reasons combine. Names shared by more than 10 records (or numbers by more than 5) are skipped as placeholders.
* Pairs dismissed in the app ("Not duplicates") are left out, and so is any record outside the credential owner's record access. Records in Recently deleted and archived contacts never appear.
* In very large workspaces (more than 50,000 companies or 100,000 contacts) the most recently updated ones are compared and `meta.truncated` is `true`.
* A workspace is compared at most every few minutes; `meta.scanned_at` says when. Pairs dismissed and records merged or deleted since are left out at once; `refresh=true` compares again now. The endpoint allows 30 requests a minute per credential.

Merge a pair with the merge endpoints above.

### Moving deals

`PATCH /api/v1/deals/:id` with a new `stage_id` moves the deal. Its `status` follows the stage type (`open`, `won`, `lost`), the move is recorded in stage history, and any workflow with the **Deal Stage Changed** trigger runs. When the team requires a lost reason, include `lost_reason` when moving to a lost stage. The pipeline's [rules](#pipeline-rules-and-required-fields) may refuse a move (`422`); fill what the stage asks for in the same request.

### Forecast category

Every deal has a `forecast_category`, what it counts as in the [forecast](https://docs.useorbitcrm.ai/developers/api/reports.md#forecast): `closed` (won), `omitted` (lost), or for an open deal `commit`, `best_case`, `pipeline` or `omitted`. An open deal follows its stage (each stage's category is set in Settings › Pipelines; until then by probability: 80% or more is `commit`, 50% or more `best_case`, else `pipeline`). Send `forecast_category` (`pipeline`, `best_case`, `commit` or `omitted`) on create or `PATCH` to set it by hand, or `null` to follow the stage again; `forecast_category_override` reads back what was set (`null` when it follows the stage). An override is for the stage it was set in: moving the deal to another stage clears it unless the same request sets one, and won or lost deals never keep one (setting one on a closed deal is a `400`). Changes are in the deal's field history and send [`deal.updated`](https://docs.useorbitcrm.ai/developers/webhooks/events#deals).

### Renewals and recurring revenue

Deals carry four optional fields, sent on create or `PATCH` like any other (`null` clears):

| Field | Description |
| - | - |
| `deal_type` | `new_business`, `renewal` or `expansion` |
| `recurring_amount` | What the deal brings in per `recurring_interval`, in the deal's currency |
| `recurring_interval` | `month` (MRR, the default) or `year` (ARR) |
| `renewal_date` | When the contract comes up for renewal, `YYYY-MM-DD` |

`renewed_from_deal_id` (read-only) names the won deal a renewal deal renews. When an admin turns on the auto-renewal (`auto_renewal` in the [deal settings](#settings): `{ "days_before": 60, "pipeline_id": "…", "stage_id": "…" }`, or `null` for off), Orbit checks once a day for won deals whose `renewal_date` is `days_before` days away or closer and still to come, and creates one renewal deal per renewal date in that pipeline and open stage: named `Renewal: <name>`, `deal_type` `renewal`, closing on the renewal date and renewing a year later, with the recurring amount (a year of it as the `amount`, else the original amount), the custom fields, the same owner (unless they can't see the pipeline) and the contacts and companies with their [labels](#associations-and-labels). It has `source` `renewal` and sends [`deal.created`](https://docs.useorbitcrm.ai/developers/webhooks/events#deals). Deleting it doesn't make Orbit create another for the same date.

Filter the list with `deal_type` and `renewal_after` / `renewal_before` (dates, on `renewal_date`). Stage rules and `required_on_create` can require `deal_type`, `recurring_amount` and `renewal_date`, and field conditions can read `deal_type`. The [`renewals` and `recurring` reports](https://docs.useorbitcrm.ai/developers/api/reports#definitions) add them up. Changes are in the field history and send [`deal.updated`](https://docs.useorbitcrm.ai/developers/webhooks/events#deals).

### Pipeline rules and required fields

Admins can ask for fields and set rules for how deals move. The database enforces them, so a write from any path (the dashboard, the API, MCP, bulk edit) is refused the same way.

| Rule | Where | What it does |
| - | - | - |
| `required_on_create` | `PATCH /crm/settings/:object` | Fields a new contact, company or deal must have. Contacts: `first_name`, `last_name`, `phone`, `company`, `job_title`, `owner_user_id`. Companies: `domain`, `industry`, `employee_count`, `annual_revenue`, `phone`, `location`, `description`, `owner_user_id`. Deals: `amount`, `close_date`, `owner_user_id`, `company_id` (a primary or associated company), `contacts` (a linked contact), `description`. Any object: `cf:<field_name>` |
| `required_fields` | `PATCH /pipelines/:id/stages/:stageId` | Fields a deal must have to enter the stage (same deal keys). A new deal also needs its first stage's fields |
| `block_skip` | `PATCH /pipelines/:id` | Deals move forward one open stage at a time; a won stage only from the last open stage; into this pipeline only at its first open stage |
| `block_backward` | `PATCH /pipelines/:id` | Deals never move back to an earlier open stage, and won or lost deals are not reopened (also not by joining the pipeline from another one). Deals leave the pipeline only for a lost stage, so a round trip through another pipeline can't undo the rule |
| `lock_closed` | `PATCH /pipelines/:id` | Won and lost deals can't be edited, their `closed_at` and `external_ids` included (board order, delete and restore still work) |

Moving a deal to a lost stage is always allowed. Rules apply when a deal enters a stage or a record is created: a deal already in a stage is not checked again. In a pipeline with a rule on, only admins can add, reorder or change the type of its stages, or delete a stage that still has deals; only admins can delete a stage with required fields. A value counts as filled when it isn't null, text has a non-space character and a list has an item; numbers (`0` too) and yes/no answers (`false` too) are filled. A required custom field that has been deleted asks for nothing. Workflows, new records from imports, CRM migrations, restoring records and an admin deleting a stage (its deals move) are not checked. A bulk import's `duplicate_mode: update` changes matched deals under the rules: a deal a rule refuses is reported as a row error.

A refused write answers `422` with the rule's code in `error`, what to do next in `error_description`, and the details:

```json theme={null}
{
  "error": "stage_required_fields",
  "error_description": "Proposal needs Amount and Budget before a deal can move there. Missing: amount, cf:budget. To move it, send amount, budget in the same update as stage_id (custom fields inside custom_fields). Required fields apply to everyone.",
  "details": {
    "code": "stage_required_fields",
    "object": "deal",
    "deal_id": "9a1d3c4e-...",
    "stage": { "id": "1f2e3d4c-...", "name": "Proposal" },
    "from_stage": { "id": "0e1d2c3b-...", "name": "Qualified" },
    "missing": [{ "key": "amount", "label": "Amount" }, { "key": "cf:budget", "label": "Budget" }],
    "can_override": false
  }
}
```

| `error` | Meaning | How to recover |
| - | - | - |
| `required_on_create` | The new record misses `details.missing` | Send them in the create request (`contact_ids` for `contacts`, `custom_fields` for `cf:` keys) |
| `stage_required_fields` | The stage needs `details.missing` | Send them with `stage_id` in one `PATCH`; link a contact first with `POST /deals/:id/contacts` |
| `stage_skip_not_allowed` | The pipeline doesn't allow skipping | Move to `details.next_stage` first (with `pipeline_id` when `details.cross_pipeline`) |
| `stage_backward_not_allowed` | The pipeline doesn't allow moving back (`details.reopen`: reopening a won or lost deal; `details.leave_pipeline`: leaving it except for a lost stage) | An admin can override |
| `closed_deal_locked` | Won and lost deals are locked | An admin can override |
| `stage_approval_required` | The stage needs an approver's sign-off (`details.approvers`; `details.pending` when a request is already waiting) | Ask with `POST /deals/:id/approvals` (see Stage approvals). No override |

`details.can_override` says whether sending `"override_rules": true` with the same `PATCH /deals/:id` would get past the rule: it does for workspace admins (and the owner) on the skip, backward and lock rules. Required fields apply to everyone. `get_crm_schema` and `GET /crm/schema` include the rules (`pipelines[].block_*`, `stages[].required_fields`, `settings.required_on_create`) and a `rules_reference`.

### Currencies and exchange rates

A deal keeps its own `amount` and `currency`. Each workspace has one currency (`default_currency` in deal settings) and, in Settings › Deals › Currencies, the other currencies it sells in, each with a rate to the workspace currency: set by hand, or taken every working day from the European Central Bank's reference rates. Every deal carries its amount converted:

* `amount_home`: the amount in `home_currency`, to the cent; `null` when the deal has no amount or its currency has no rate.
* `amount_home_rate`: the rate used (`1` for a deal in the workspace currency). `amount_home_rate_at`: when it was taken.
* An open deal converts at the current rate and follows it when it changes. A won or lost deal keeps the rate it closed at, so its value doesn't move with the market; reopening converts it again.
* Orbit keeps these fields; they can't be written. A rate change doesn't change `updated_at` or send `deal.updated`.

Board totals, the pipeline summary, the [Reports API](https://docs.useorbitcrm.ai/developers/api/reports.md), goals and the forecast add `amount_home`. A deal whose currency has no rate is counted but left out of amounts (`other_currency_count` in reports, `unconverted_count` in the summary).

`GET /api/v1/currencies` (`deals:read`) lists them:

```json theme={null}
{
  "data": {
    "home_currency": "USD",
    "currencies": [
      {
        "code": "EUR",
        "rate": 1.087,
        "source": "feed",
        "effective_from": "2026-10-30T00:00:00+00:00",
        "feed_checked_at": "2026-10-30T16:30:04+00:00",
        "updated_at": "2026-10-30T16:30:04+00:00",
        "history": [
          { "rate": 1.087, "source": "feed", "home_currency": "USD", "effective_from": "2026-10-30T00:00:00+00:00", "recorded_at": "2026-10-30T16:30:04+00:00" }
        ]
      }
    ],
    "feed": { "date": "2026-10-30", "currencies": ["AUD", "CAD", "EUR", "GBP", "JPY", "USD"] }
  }
}
```

`rate` is units of `home_currency` per one unit of `code` (`null` while there is none). `effective_from` is when it took effect: the publication day of a feed rate (an outage keeps the last one) or when a rate was set by hand. `history` holds the 20 most recent rates, newest first. Currencies are managed in Settings › Deals.

### Stage approvals

A stage can ask for an approver's sign-off before deals enter it: `approval_required` and `approver_ids` on `PATCH /pipelines/:id/stages/:stageId` (admins). Approvers are admins or editors who can see the deal; none listed, or none who can see it, means the workspace admins. A move into the stage (or a new deal created in it) by anyone else answers `422 stage_approval_required`, from the dashboard, the API, MCP and workflows alike; approvers move deals there directly. Imports and CRM migrations are not checked.

| Endpoint | What it does |
| - | - |
| `POST /deals/:id/approvals` | `{ stage_id, note? }`: ask to move the deal there. The pipeline rules are checked first (a move a rule refuses answers that rule's `422`). The approvers are notified. `201` with the request; `200` with the one already waiting |
| `GET /deals/:id/approvals` | The deal's requests, newest first: `status` (`pending`, `approved`, `rejected`, `canceled`), `stage_id`, `from_stage_id`, `requested_by`, `note`, `decided_by`, `decision_note`, `applied_at`; pending ones add `approvers`, `can_decide` and `can_cancel` for you |
| `PATCH /deals/:id/approvals/:approvalId` | `{ action: "approve" \| "reject" \| "cancel", note? }`. An approver other than whoever asked approves or rejects; whoever asked, or an admin, cancels. `409` once it was decided |
| `GET /deal-approvals` | Requests waiting for you to decide, with the deal's name and amount |

Approving moves the deal (`moved: true`, and "Deal Stage Changed" workflows run) when it is still in the stage it was asked from, the stage isn't a lost stage (that needs a reason, so whoever asked moves it) and the pipeline rules still allow it. Otherwise `not_moved_reason` says why (`moved_since`, `lost_stage`, `rule` with the rule's `violation`, `already_there`, `deleted`) and the approval lets the next move into that stage through. An approval counts once: a deal moved out and back needs a new one. It also doesn't outlive what was approved: an approval not used yet stops counting when the deal's `amount`, `currency`, `pipeline_id` or `owner_user_id` changes, and 30 days after it was given. Such an approval reads `expired: true` with `expired_reason` (`changed` or `expired`) and `expires_at`; the gate treats it as missing, so ask again. A workflow's Update Deal step that hits the gate makes its other changes and asks on the workflow's behalf.

### Deal splits

With the deal setting `deal_splits` on (admins, `PATCH /crm/settings/deal`), a deal's credit can be shared between people by percentage. No splits: the owner has all of it.

| Endpoint | What it does |
| - | - |
| `GET /deals/:id/splits` | `{ enabled, owner_user_id, splits: [{ user_id, percent }] }`; `GET /deals/:id?include=splits` adds `splits` to the deal |
| `PUT /deals/:id/splits` | `{ splits: [{ user_id, percent }] }` replaces them: workspace members, percentages with at most two decimals adding up to exactly `100`, at most 20 people; `[]` gives the owner everything again. `409 splits_disabled` while the setting is off. Won and lost deals in a pipeline with `lock_closed` take changes from admins only |

Reports follow the splits: `GET /reports/sales/win_rate` adds `split_credit.by_person` (each person's credited `won_amount` and `won_deals` share) while splits are on, and Revenue won goals count the credited amount.

### Won and lost deals

A deal is closed by moving it to a stage whose `kind` is `won` or `lost`; there is no separate close call, and `status` cannot be set directly. The deal then carries `status` (`won` / `lost`), `closed_at` (when it closed; `null` again if it is reopened) and, for lost deals, `lost_reason`. To find the deals closed in a period:

```bash theme={null}
curl "https://app.useorbitcrm.ai/api/v1/deals?status=won&closed_after=2026-10-01&closed_before=2026-11-01" \
  -H "Authorization: Bearer $ORBIT_API_KEY"
```

`closed_after` / `closed_before` take an ISO 8601 date or date-time and compare on `closed_at`, so open deals never match. The webhook events [`deal.won`, `deal.lost` and `deal.reopened`](https://docs.useorbitcrm.ai/developers/webhooks/events#deals) report the same transitions as they happen.

```bash theme={null}
curl -X PATCH https://app.useorbitcrm.ai/api/v1/deals/DEAL_ID \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "stage_id": "STAGE_ID", "amount": 24000 }'
```

Creating a deal without `pipeline_id` uses the default pipeline and its first stage; `currency` defaults to the team's deal setting.

## Responses

### Pipelines and stages

`GET /api/v1/pipelines` returns every pipeline with its stages in order; `GET /api/v1/pipelines/:id` one of them. `kind` says what a stage does to a deal: `open` keeps it in progress, `won` / `lost` close it.

```json theme={null}
{
  "data": [
    {
      "id": "e1f2a3b4-...",
      "team_id": "7c1d0e2f-...",
      "name": "Sales",
      "position": 0,
      "is_default": true,
      "visibility": "everyone",
      "block_skip": false,
      "block_backward": false,
      "lock_closed": true,
      "created_at": "2026-09-01T09:00:00Z",
      "stages": [
        { "id": "1f2e3d4c-...", "team_id": "7c1d0e2f-...", "pipeline_id": "e1f2a3b4-...", "name": "Proposal", "position": 3, "probability": 60, "kind": "open", "color": "#f59e0b", "required_fields": ["amount", "cf:budget"] },
        { "id": "2a3b4c5d-...", "team_id": "7c1d0e2f-...", "pipeline_id": "e1f2a3b4-...", "name": "Closed won", "position": 5, "probability": 100, "kind": "won", "color": "#22c55e", "required_fields": [] },
        { "id": "3b4c5d6e-...", "team_id": "7c1d0e2f-...", "pipeline_id": "e1f2a3b4-...", "name": "Closed lost", "position": 6, "probability": 0, "kind": "lost", "color": "#ef4444", "required_fields": [] }
      ]
    }
  ]
}
```

### Deals

`GET /api/v1/deals/:id` (and each item of the list) embeds the company and the linked contacts; `is_primary` marks the primary contact, listed first.

```json theme={null}
{
  "data": {
    "id": "9a1d3c4e-...",
    "team_id": "7c1d0e2f-...",
    "pipeline_id": "e1f2a3b4-...",
    "stage_id": "2a3b4c5d-...",
    "name": "Acme renewal",
    "amount": 12000,
    "currency": "USD",
    "amount_home": 12000,
    "amount_home_rate": 1,
    "amount_home_rate_at": "2026-10-27T14:30:05Z",
    "home_currency": "USD",
    "close_date": "2026-11-15",
    "owner_user_id": "d4e5f6a7-...",
    "company_id": "3a4b5c6d-...",
    "status": "won",
    "closed_at": "2026-10-27T14:30:05Z",
    "lost_reason": null,
    "description": null,
    "forecast_category": "closed",
    "forecast_category_override": null,
    "deal_type": "renewal",
    "recurring_amount": 1000,
    "recurring_interval": "month",
    "renewal_date": "2027-11-15",
    "renewed_from_deal_id": "8b9c0d1e-...",
    "custom_fields": { "lead_source": "Inbound" },
    "position": -1761575405000,
    "source": "manual",
    "stage_changed_at": "2026-10-27T14:30:05Z",
    "created_by": "d4e5f6a7-...",
    "created_at": "2026-09-20T10:00:00Z",
    "updated_at": "2026-10-27T14:30:05Z",
    "company": { "id": "3a4b5c6d-...", "name": "Acme" },
    "contacts": [
      { "id": "5c6d7e8f-...", "email": "ada@acme.com", "first_name": "Ada", "last_name": "Lovelace", "is_primary": true }
    ],
    "tags": [{ "id": "6b7c8d9e-...", "name": "Renewal", "slug": "renewal", "color": "#10B981" }]
  }
}
```

`tags` is on `GET /api/v1/deals/:id`; list items and write responses leave it out.

### Activity: last contacted, next activity and counts

Contacts, companies and deals read with an `activity` object (on `GET` of one record and on every list item; write responses leave it out). It is read-only and kept current by Orbit as engagement happens:

```json theme={null}
"activity": {
  "last_contacted_at": "2026-10-27T15:02:11Z",
  "last_activity_at": "2026-10-29T09:40:00Z",
  "next_activity_at": "2026-10-31T14:00:00Z",
  "next_activity": { "type": "task", "id": "4f6a2b1c-..." },
  "emails_sent": 12,
  "calls": 3,
  "meetings": 2
}
```

| Field | What counts |
| - | - |
| `last_contacted_at` | The last email sent (one-to-one or sequence, once it has gone out: `sent`, `delivered`, `opened`, `replied`, or `complained` after delivery; bounced, failed, cancelled and scheduled emails never count), call (a dialer call that completed, was busy or had no answer, or a logged call) or meeting that has started (booked or logged; not cancelled, rescheduled or a no-show) |
| `last_activity_at` | Any of those, plus inbound email, texts, notes, completed tasks and form submissions |
| `next_activity_at`, `next_activity` | The earliest open task with a due date (an overdue one stays next until it is done) or upcoming meeting. `type` is `task` (`/api/v1/tasks/:id`), `meeting` (a booking, `/api/v1/meetings/:id`) or `logged_meeting` (`/api/v1/activities/:id`). `id` is `null` when you can't see that task or meeting (see below) |
| `emails_sent`, `calls`, `meetings` | Counts by the same rules; upcoming meetings are included |

A company counts its own activity and its contacts'; a deal its own and its linked contacts'. An item linked to a deal only (a note on the deal) counts for the deal, not for its companies; an activity logged from a deal page names the deal's primary company and contact, so it counts for them too. Contacts in Recently deleted don't count. Each item counts once per record. `activity` is `null` while the feature is being rolled out to a workspace's database.

These are engagement totals, not a timeline. A company's or deal's counts and dates include all of its contacts, including their form submissions (which company and deal timelines don't list) and contacts your record access doesn't let you see; they say nothing about any one contact. The next activity's `id` does, so it is `null` unless you can see that item: a task needs its assignee within your task access, and any item needs the contact, company and deal it links to to be visible to you. `next_activity.type` and `next_activity_at` are always given.

Deals also carry `days_to_close` (whole days from creation to close; `null` while open), `stage_entered_at` (when the deal entered its current stage) and `days_in_stage` (whole days in it; `null` once closed). `GET /api/v1/deals/:id` adds `stage_times`: one entry per stage the deal has been in, `{ stage_id, first_entered_at, last_entered_at, visits, total_seconds, is_current }`, from its stage history. Re-entering a stage adds a visit and its time; the current stage of an open deal counts up to now, and a won or lost deal's final stage has no duration.

Saved contact views filter on `lastContactedAt`, `lastActivityAt` and `nextActivityAt` (dates; `lastContactedAt` and `lastActivityAt` also take `within_last` and `more_than_ago`, and `nextActivityAt` takes `within_next`, each with a whole number of days as the value) and `emailsSent`, `callCount`, `meetingCount` (numbers). `createdAt` (Create date) and `updatedAt` (Last modified) take the same date operators as `lastActivityAt`, and the `created_at` and `updated_at` columns sort by them; a view without a sort lists the newest contacts first. The `last_activity` column and sort is "Last form submission", the contact's latest form submission (empty for a contact that never submitted one, and sorted last); `last_activity_date` is the rollup's `last_activity_at`. The retired "First seen" keys (`first_seen`, `firstSubmission`) are still accepted and read as `created_at` / `createdAt`.

### The schema

`GET /api/v1/crm/schema` describes the model in one call: for each object its `standard_fields` (a list of `{ field_name, label, type }` for the columns above) and `custom_fields` (the definitions from `/api/v1/crm/fields`, keyed by `field_name`), the `pipelines` in the shape above, `tags`, `team_members` (`{ id, name, role }`) and `member_groups`. A record's `custom_fields` object is keyed by those `field_name`s, and a webhook `changes` entry for a custom field reports the whole object, as in `"custom_fields": { "from": { "tier": "silver" }, "to": { "tier": "gold" } }`.

Lists return `{ "data": [...], "meta": { "total", "page", "per_page", "total_pages" } }`; single records return `{ "data": {...} }`. Errors return `{ "error", "error_description" }` with a 4xx status (rule violations add `details`), for example `409 in_use` when deleting a stage that still has deals without `move_deals_to`. A team can have at most 50 pipelines, 200 saved contact views and 100 teams (member groups); creating one more answers `409 limit_reached`.

> [card] [MCP tools](https://docs.useorbitcrm.ai/mcp/tools.md)
>
> The same operations as MCP tools

> [card] [Rate Limits](https://docs.useorbitcrm.ai/developers/api/rate-limits.md)
>
> API rate limit details


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