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

# Users and Workspaces

> Sync your product's users and accounts into Orbit in ten minutes. A user is a contact; a workspace is an account in your product, linked to a company, with its own fields. One request each, plus imports, MCP tools and webhook events.

A SaaS company's CRM needs to know who signed up, which account they belong to, what plan it is on and when they were last seen. Orbit keeps that in two places, the way Attio's Users and Workspaces objects do, fitted to one rule: **a person is a contact**.

* **Users** are the people who use your product. A user is the contact with the login email, created when none has it. The account in your product (its id there, when they signed up, when they were last seen, which workspaces they are in and with what role) hangs off the contact, and what your product knows about the person lands on the contact as fields in the **Product** group. So product usage sits next to the emails, calls, meetings and deals your team already has with that person, and it works in every filter, saved view, lead score, workflow, sequence and report that reads contact fields.
* **Workspaces** are the accounts in your product (teams, organizations, tenants): their id and name, their domain, their plan, seats and MRR as custom fields, and their members. A workspace is its own object, linked to the **company** with its domain, because an account is not a company: one company can own several, and a gmail.com sign-up has none.

> [!NOTE]
>
> **Users are not your Orbit teammates, and workspaces are not Orbit workspaces.** Teammates are `team_members` (`GET /api/v1/crm/schema`); your own Orbit workspace is `team` in `GET /api/v1/me`. Everything on this page is about *your product's* people and accounts.

## Set up in ten minutes

**1. Create an API key**

**Settings → API keys**, preset **CRM agent** (it carries `contacts:write`, `companies:write` and `crm:write`). Users use the contacts scopes, workspaces the companies scopes, and adding workspace fields needs `crm:write`. An OAuth app or an MCP client with the same scopes works the same way.

**2. Map your ids**

Every write is matched on the id *your* system already has, so nothing in Orbit needs to exist first. The ids are text (numbers are stored as text, up to 255 characters).

| In your product | Send it as | On |
| - | - | - |
| The user's id | `user_id` | a user |
| The user's login email (required) | `email` | a user |
| The account, organization, team or tenant id | `workspace_id` | a workspace, and in a user's `workspaces` |
| The account's domain, when you know it | `domain` | a workspace (links the company with that domain) |
| A user's role in the account | `role` | an entry of a user's `workspaces` |
| When they signed up, when they were last seen | `signed_up_at`, `last_seen_at` | both (ISO 8601 or Unix milliseconds) |
| Plan, seats, MRR, billing status | `custom_fields` | a workspace (define the fields first, step 4) |
| Seat type, usage counts, sign-up source | `custom_fields` | a user (they land on the contact; no setup needed) |

You never send Orbit ids. `contact_id` and `company_id` are optional overrides for the rare account that should attach to a specific contact or company; otherwise the email and the domain decide. There is no separate "company id" to send for a user: the user's workspaces carry the account, and the account's company reaches the user's contact by itself (see [how a user connects to everything else](#how-a-user-connects-to-everything-else)).

**3. Send a user when someone signs up**

`PUT /api/v1/users` creates or updates the user by `user_id`. The first write turns Users on in the app.

```bash theme={null}
curl -X PUT https://app.useorbitcrm.ai/api/v1/users \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "u_8812",
    "email": "ada@acme.com",
    "name": "Ada Lovelace",
    "signed_up_at": "2026-03-14T09:30:00Z",
    "custom_fields": { "seat_type": "Editor" },
    "workspaces": [{ "workspace_id": "w_301", "role": "admin" }]
  }'
```

```js theme={null}
// Node, in your sign-up handler
await fetch('https://app.useorbitcrm.ai/api/v1/users', {
  method: 'PUT',
  headers: { Authorization: `Bearer ${process.env.ORBIT_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    user_id: user.id,
    email: user.email,
    name: user.fullName,
    signed_up_at: user.createdAt,
    workspaces: user.memberships.map((m) => ({ workspace_id: m.orgId, role: m.role })),
  }),
})
```

Orbit finds the contact with that email (as its primary or another address) or creates one named Ada Lovelace, attaches the account to it, writes `seat_type` on the contact, adds the account to workspace `w_301` (creating a bare workspace with that id if it has never been seen) and fills the contact's Product fields. The answer carries the account, its `workspaces` and its `contact`, with `created: true` and a `201` the first time.

**4. Send a workspace when an account is created or its plan changes**

Define the account fields once, under **Settings → Users & workspaces** or with the API; a workspace write that names a field you haven't defined is refused with `400`, so a typo never creates a field by accident.

```bash theme={null}
curl -X POST https://app.useorbitcrm.ai/api/v1/workspaces/fields \
  -H "Authorization: Bearer $ORBIT_API_KEY" -H "Content-Type: application/json" \
  -d '{ "label": "Plan", "field_type": "select", "options": ["Free", "Pro", "Enterprise"] }'
```

Define `Seats` and `MRR` the same way with `"field_type": "number"` (field keys are the lowercased labels: `plan`, `seats`, `mrr`). Then `PUT /api/v1/workspaces` by `workspace_id`:

```bash theme={null}
curl -X PUT https://app.useorbitcrm.ai/api/v1/workspaces \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "workspace_id": "w_301",
    "name": "Acme",
    "domain": "acme.com",
    "signed_up_at": "2026-03-14T09:30:00Z",
    "custom_fields": { "plan": "Pro", "seats": 12, "mrr": 1188 }
  }'
```

Orbit links the company with `acme.com`, creating it when there is none (a setting), and fills the workspace. Send `users` on a workspace, or `workspaces` on a user, whichever side your code finds easier: they are the same links, and a sent list replaces the stored memberships.

**5. Backfill, and keep last seen fresh, in batches**

`POST /api/v1/users/import` and `POST /api/v1/workspaces/import` take up to 1,000 rows per call, each row shaped like a `PUT` body, matched the same way. Use them for the initial load and for anything that changes often. In particular, don't call `PUT` on every sign-in: a key may make 300 writes a minute, and sign-ins exceed that quickly. Send sign-ups live and `last_seen_at` hourly or nightly from your database or warehouse, one import call per thousand users. Re-running the same file is safe; imports count against a budget of 100,000 rows an hour.

```bash theme={null}
curl -X POST https://app.useorbitcrm.ai/api/v1/users/import \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "rows": [
    { "user_id": "u_8812", "email": "ada@acme.com", "last_seen_at": "2026-11-04T16:20:00Z" },
    { "user_id": "u_8813", "email": "grace@acme.com", "last_seen_at": "2026-11-04T15:02:00Z", "workspaces": ["w_301"] }
  ] }'
```

The answer lists `created`, `updated`, `skipped`, the rows refused and why, and the Orbit id of every row. Every write is keyed by your id, so a replay never duplicates a record; but `workspaces` and `users` replace the stored memberships with what the write says, so send a record's writes in order (don't replay an old snapshot after a newer one), or leave memberships to the one job that holds the truth. If you already send Segment-style `identify` and `group` calls, map `identify` to a user (`userId` → `user_id`, `traits.email` → `email`, the other traits to `custom_fields`) and `group` to a workspace (`groupId` → `workspace_id`, `traits.website` or `traits.domain` → `domain`, `traits.name` → `name`), with the user's `workspaces` carrying the `groupId`. That is the mapping Attio's Segment destination uses.

**6. See it in Orbit**

**Contacts** gains a shared **Product users** view (everyone with a product user id, most recently seen first) and the five Product fields as columns and filters. A contact page shows *In your product*: the account, its dates, its workspaces and roles. **Workspaces** appears under *More tools* with a page per account, and a company page shows *Workspaces in your product*. `GET /api/v1/users?user_id=u_8812` answers the account with its contact, for a look-up from your side.

## What lands where

### On the contact

The five **Product** fields are contact custom fields that Orbit defines on first sync and keeps up to date from the contact's accounts. They are read-only in spirit: a value typed by hand is overwritten by the next sync.

| Field | Type | Holds |
| - | - | - |
| `product_user_id` | text | The contact's id in your product (their first account when they have several) |
| `product_signed_up_at` | date-time | When they signed up |
| `product_last_seen_at` | date-time | When they last used your product |
| `product_workspace_count` | number | How many of your workspaces they belong to |
| `product_workspaces` | text | Those workspaces, by name |

Because they are contact fields, "users who haven't signed in for 30 days" is a Contacts filter, a saved view, a lead scoring rule, a *Field changed* workflow trigger, a sequence audience or an export, with no extra wiring. Anything else your product knows about a *person* (seat type, usage counts, sign-up source, first and last UTM) goes in `custom_fields` on the user write and lands on the contact too, so it gets the same treatment. Define those fields with `POST /api/v1/crm/fields` (`object: "contact"`) or send them and they are kept as text.

### On the account

`GET /api/v1/users/{id}` and the lists carry the account itself: `user_id`, `email`, `name`, `avatar_url`, `contact_id`, `signed_up_at`, `last_seen_at`, its `workspaces` with roles, and the `contact` it belongs to. A contact can hold more than one account (one person, two logins); a merge of two contacts pulls both onto the kept contact.

### On the workspace

Built-ins: `workspace_id`, `name`, `domain`, `company_id`, `avatar_url`, `signed_up_at`, `last_seen_at`, its `users` with roles. Custom fields are the workspace's own, defined under **Settings → Users & workspaces** or with `POST /api/v1/workspaces/fields` in any of the [custom field types](https://docs.useorbitcrm.ai/developers/api/crm.md#custom-fields); a write that names an undefined field is refused with `400`. Plan, subscription dates, billing status, MRR / ARR, seats and usage belong here: they describe the account, not a person.

## How a user connects to everything else

Because a user is a contact, it is connected to every object a contact is, and the sync makes the links for you. Each row is a link, how it is made, and how you use it from the API.

| Link | How it is made | Use it |
| - | - | - |
| User → **contact** | Always: the contact with the login email, created when none has it (`contact_id` on a write attaches the account to another contact) | `contact_id` and `contact` come back on every user read; `GET /api/v1/users?user_id=u_8812` finds the account from your id |
| Contact → **product id** | The first account's `user_id` is written to the contact's `external_ids` under the source `product` | Name the contact by your id anywhere a link accepts one: `contact_external_id: "u_8812", contact_external_source: "product"` on notes, tasks, logged activities, files and deal imports, and `GET /api/v1/contacts?external_id=u_8812&external_source=product`. A contact with several accounts carries only the first one's id here: `GET /api/v1/users?contact_id=` lists them all |
| User ↔ **workspaces** | `workspaces` on a user or `users` on a workspace (the same links from either side; a sent list replaces the stored one); `POST /api/v1/workspaces/{id}/users` adds one member, `DELETE .../users/{user}` removes one | `workspaces` on a user read, `users` on a workspace read, `GET /api/v1/users?workspace_id=w_301` for a workspace's members, `GET /api/v1/workspaces?user_id=u_8812` for a user's workspaces |
| User → **company** | Two ways. By email: a new contact links to the company its work-email domain points at, as a form submission would. By account: when a user joins a workspace that has a company, the contact is linked to that company, as its primary company when it has none, else as another company it is at. So a `gmail.com` user in Acme's workspace is at Acme. Never unlinked by the sync | The contact's `company_id` and `GET /api/v1/contacts/{id}?include=associations`; the company page's People panel |
| Workspace → **company** | The company with the workspace's `domain` (or `company_domain`), created when none has it while **Create companies for new workspaces** is on (never for personal mailbox domains); `company_id` names one outright | `company_id` on a workspace read, `GET /api/v1/workspaces?company_id=` |
| User → **deals** | Through the contact: a deal's contacts are contacts | `POST /api/v1/deals` with `contact_ids: [<contact_id>]`, or a deals import row with `contact_external_ids: ["u_8812"]` and `external_source: "product"`; `GET /api/v1/deals?contact_id=` |
| Workspace → **deals** | Through its company: a deal's companies are companies | `GET /api/v1/deals?company_id=<the workspace's company_id>` |
| User → **notes, tasks, logged activity, files** | Through the contact | `contact_id`, `contact_email` or `contact_external_id` + `contact_external_source: "product"` on `POST /api/v1/notes`, `/tasks`, `/activities`, `/files` |
| User → **sequences, lead scoring, workflows, lists** | Through the contact's fields: the Product fields and anything sent in `custom_fields` | Filters, views, scoring rules and *Field changed* triggers on `product_last_seen_at` and the rest |

Two examples. Log a call that happened in your support tool for a product user, naming them by your id:

```bash theme={null}
curl -X POST https://app.useorbitcrm.ai/api/v1/activities \
  -H "Authorization: Bearer $ORBIT_API_KEY" -H "Content-Type: application/json" \
  -d '{ "type": "call", "occurred_at": "2026-11-04T15:00:00Z", "direction": "inbound", "title": "Asked about SSO",
        "contact_external_id": "u_8812", "contact_external_source": "product" }'
```

Open an expansion deal for the account, on the contact the account belongs to and its company:

```bash theme={null}
curl -X POST https://app.useorbitcrm.ai/api/v1/deals \
  -H "Authorization: Bearer $ORBIT_API_KEY" -H "Content-Type: application/json" \
  -d '{ "name": "Acme: Pro to Enterprise", "amount": 12000, "contact_ids": ["c1d2e3f4-..."], "company_id": "3a4b5c6d-..." }'
```

The ids are the `contact_id` and the workspace's `company_id` from the user and workspace reads.

In the app, a contact page shows *In your product* (the account, its dates and its workspaces with roles) and a company page shows *Workspaces in your product* and, in its People panel, the users of its workspaces. Merging two companies moves the merged company's workspaces to the survivor; merging two contacts moves the accounts. What a product does not get today: a direct workspace-to-deal link (use the company), and notes or tasks on a workspace itself (put them on the company or the contact).

## Endpoints

Users use the `contacts:*` scopes and workspaces the `companies:*` scopes, so an existing CRM key or app works. Workspace field endpoints need `crm:read` / `crm:write`. Deleting an account needs `contacts:delete` and a workspace `companies:delete`; both are for good, since the product can send the record again, and the contact and company stay.

| Method | Endpoint | Description |
| - | - | - |
| `GET` / `POST` / `PUT` | `/api/v1/users` | List accounts (`search`, `email`, `user_id`, `contact_id`, `workspace_id` for a workspace's members, `updated_since`, `sort` / `order`, `page`, `per_page`) / create (`409` when the `user_id` or `email` exists) / create or update, matched by `user_id` (`?matching_attribute=email` matches by email instead, and the `user_id` may change) |
| `GET` / `PATCH` / `DELETE` | `/api/v1/users/{id}` | Read with `workspaces` and `contact` / change the fields sent (`custom_fields` merges into the contact's; `workspaces` replaces) / delete the account for good |
| `POST` | `/api/v1/users/import` | Up to 1,000 rows, matched by `user_id`; `duplicate_mode` `update` (default) or `skip`. Answers `created`, `updated`, `skipped`, `errors`, `warnings` and `records` (the Orbit id per row) |
| `GET` / `POST` / `PUT` | `/api/v1/workspaces` | List (`search`, `workspace_id`, `domain`, `company_id`, `user_id` for a user's workspaces, `updated_since`, `sort` / `order`) / create / create or update, matched by `workspace_id` |
| `GET` / `PATCH` / `DELETE` | `/api/v1/workspaces/{id}` | Read with `users` (the first 1,000 members) and `user_count` (exact) / change the fields sent (`users` replaces) / delete for good |
| `POST` | `/api/v1/workspaces/import` | Up to 1,000 rows, matched by `workspace_id`; users named by an unknown `user_id` are reported in `warnings` |
| `GET` / `PUT` / `POST` | `/api/v1/workspaces/{id}/users` | The members with their roles / replace them `{ users: [...] }` / add one `{ user_id \| id, role?, joined_at? }` |
| `DELETE` | `/api/v1/workspaces/{id}/users/{user}` | Remove a member (`user` is the Orbit id or the product `user_id`) |
| `GET` / `POST` | `/api/v1/workspaces/fields` | The custom fields of workspaces / add one: `{ label, field_type?, field_name?, options?, description? }` |
| `PATCH` / `DELETE` | `/api/v1/workspaces/fields/{field_name}` | Change the label, options, description or position (never the type) / remove it and its values |
| `GET` / `PATCH` | `/api/v1/crm/settings/product` | The settings: `users_enabled`, `workspaces_enabled`, `workspaces_create_companies` |

A membership is `"w_301"` or `{ "workspace_id": "w_301", "role": "admin", "joined_at": "2026-03-14T09:30:00Z" }` on a user (`user_id` on a workspace). `signed_up_at` and `last_seen_at` take ISO 8601 or Unix milliseconds; the ids are text of up to 255 characters, numbers stored as text. Lists use `page` / `per_page` (up to 100) and answer `{ data, meta }`; `updated_since` lists the records changed at or after an instant, oldest first, for a catch-up sync. Import rows count against the workspace's [hourly import budget](https://docs.useorbitcrm.ai/developers/api/rate-limits.md). The schema (`GET /api/v1/crm/schema`) lists `objects.user` (with `contact_fields`) and `objects.workspace` (with its `custom_fields`).

A user reads as:

```json theme={null}
{
  "id": "0f3c6a1e-...",
  "user_id": "u_8812",
  "email": "ada@acme.com",
  "name": "Ada Lovelace",
  "avatar_url": null,
  "contact_id": "c1d2e3f4-...",
  "signed_up_at": "2026-03-14T09:30:00.000Z",
  "last_seen_at": "2026-11-04T16:20:00.000Z",
  "created_at": "2026-11-04T16:21:03.120Z",
  "updated_at": "2026-11-04T16:21:03.120Z",
  "workspaces": [{ "id": "5e3fb280-...", "workspace_id": "w_301", "name": "Acme", "role": "admin", "joined_at": null }],
  "contact": {
    "id": "c1d2e3f4-...",
    "email": "ada@acme.com",
    "first_name": "Ada",
    "last_name": "Lovelace",
    "company_id": "3a4b5c6d-...",
    "lifecycle_stage": null,
    "custom_fields": { "seat_type": "Editor", "product_user_id": "u_8812", "product_signed_up_at": "2026-03-14T09:30:00.000Z", "product_last_seen_at": "2026-11-04T16:20:00.000Z", "product_workspace_count": 1, "product_workspaces": "Acme" },
    "external_ids": { "product": "u_8812" }
  }
}
```

## Webhooks

Subscribe to `user.created`, `user.updated`, `user.deleted` (with `contacts:read`) for the account itself, and `workspace.created`, `workspace.updated`, `workspace.user_added`, `workspace.user_removed`, `workspace.deleted` (with `companies:read`). Because the product fields live on the contact, `contact.updated` also fires when a contact's last seen moves or they join a workspace, with `custom_fields` in `changes`, exactly as it does for a dialer call. See [Webhook events](https://docs.useorbitcrm.ai/developers/webhooks/events.md#users).

## MCP

AI agents get the same through five tools: `list_product_users`, `upsert_product_user`, `list_product_workspaces`, `upsert_product_workspace` and `create_workspace_field`, with the settings in `update_crm_settings` (`object: "product"`). Because a user is a contact, `get_contact` and `list_contacts` already show an agent the product fields. See [Tools](https://docs.useorbitcrm.ai/mcp/tools.md).

## How this compares

| | Orbit | Attio | HubSpot |
| - | - | - | - |
| Model | A user is a contact; the account and the product fields hang off it. Workspaces are their own object | Users and Workspaces are separate objects; a user *links* to a Person (optional), so one human is two records | No product-user object: contacts, companies and, on Enterprise only, up to 10 custom objects |
| Identity | `user_id` / `workspace_id` from your product, unique; `PUT` asserts by it (or by email) | `user_id` / `workspace_id`, unique; `PUT` asserts by a `matching_attribute` | A unique custom property per object; batch upsert by `idProperty` |
| Where product data works | On the contact and the company: filters, views, scoring, workflows, sequences, reports | Through relationship paths in views (one hop); automations on any object | Custom events and the Customer Success workspace (Enterprise) |
| Links | User → contact by email (always), workspace → company by domain (created on demand), users ↔ workspaces | You pass the Person and Company references, or `if_unmatched: create` | Associations you define per custom object |
| Setup | One `PUT` turns it on; the Product fields and the Product users view appear by themselves | Enable the objects in settings, add attributes, then write | Design custom objects and properties first |

What is not here yet: product *events* (an activity feed of what each user did), workflow triggers on workspace changes, and rollups from workspaces onto companies (plan and MRR on the company). Webhooks cover the second today; the third is next.


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