Skip to main content
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, so an agent can set up a CRM end to end: learn the model, add fields, build pipelines, then create and move deals.
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

Create a key with the CRM agent preset in 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 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.

Configuration

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

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.

Settings

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

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

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: 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, which lists the oldest change first. A column outside the list, or an order other than asc / desc, is a 400 invalid_request.

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

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

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.

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. The limits and the recommended order are on Migrating from another CRM.

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.

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.

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

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:
  • 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.
  • Webhooks. company.merged / deal.merged 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:
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:
  • 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 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: 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.

Renewals and recurring revenue

Deals carry four optional fields, sent on create or PATCH like any other (null clears): 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: { "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. It has source renewal and sends deal.created. 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 add them up. Changes are in the field history and send deal.updated.

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. 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:
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, 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:
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. 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. 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:
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 report the same transitions as they happen.
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.

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

MCP tools

The same operations as MCP tools

Rate Limits

API rate limit details